diff --git a/.github/release-package-groups.json b/.github/release-package-groups.json index 4bab01c6a..314c97810 100644 --- a/.github/release-package-groups.json +++ b/.github/release-package-groups.json @@ -6,6 +6,7 @@ "packages/lib/package.json", "packages/cli/package.json", "packages/ui/package.json", + "packages/client/package.json", "packages/electron/package.json", "packages/electron/admin-tools/package.json" ], diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f394dfb4d..c9a18c613 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,10 +107,23 @@ jobs: - name: Type check admin UI run: cd packages/ui && bun run check + - name: Type check UI kit + run: cd packages/ui-kit && bun run check + + - name: Type check client app + run: cd packages/client && bun run check + - name: Type check CLI run: cd packages/cli && bun run typecheck - - name: Test (sdk + guardian + portals) + # Client-bundle purity gate (plan §8.5/§8.10): packages/client/tests/ + # purity.test.ts greps the BUILT bundle for '@openpalm/lib' and + # '/api/host' and deliberately FAILS when build/ is absent — so the + # client must be built before `bun run test` runs packages/client. + - name: Build client app (input to the client purity gate) + run: bun run client:build + + - name: Test (sdk + guardian + portals + client purity gate) run: bun run test - name: Validate Docker Compose manifests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6de621436..87535d920 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,8 +6,8 @@ name: Release # its own slice. A partial unit will silently leave the other units behind (e.g. # `platform` does NOT touch the portal image or the discord/slack npm adapters). # For a complete, coordinated release of the whole platform, use `all`. -# platform — @openpalm/lib + openpalm (CLI) + @openpalm/ui (npm) + admin-tools + -# CLI native binaries + optional Electron + git tag + GitHub release +# platform — @openpalm/lib + openpalm (CLI) + @openpalm/ui + @openpalm/client (npm) + +# admin-tools + CLI native binaries + optional Electron + git tag + GitHub release # (add include_images=true to also rebuild guardian + assistant images). # PARTIAL: no portal image, no portal npm, no voice. # portals — @openpalm/discord-portal + @openpalm/slack-portal npm packages @@ -26,7 +26,7 @@ name: Release # Always pass version explicitly (the version the existing npm packages are at). PARTIAL. # all — COMPLETE coordinated release. Publishes EVERY unit at one version, # flag-free (no include_images / include_electron needed): -# npm: @openpalm/{lib,ui,guardian,skeleton} + openpalm (CLI) +# npm: @openpalm/{lib,ui,client,guardian,skeleton} + openpalm (CLI) # + @openpalm/{discord,slack}-portal # images: openpalm/{assistant,guardian,portal} (+ :latest when stable) # electron installers (mac/linux/win) + CLI native binaries @@ -172,8 +172,8 @@ jobs: // platform release can never compute a next version at or below an // already-published skeleton/guardian, which npm rejects. const npmPackages = { - platform: ['@openpalm/lib', 'openpalm', '@openpalm/ui', '@openpalm/skeleton', '@openpalm/guardian'], - all: ['@openpalm/lib', 'openpalm', '@openpalm/ui', '@openpalm/skeleton', '@openpalm/guardian'], + platform: ['@openpalm/lib', 'openpalm', '@openpalm/ui', '@openpalm/client', '@openpalm/skeleton', '@openpalm/guardian'], + all: ['@openpalm/lib', 'openpalm', '@openpalm/ui', '@openpalm/client', '@openpalm/skeleton', '@openpalm/guardian'], guardian: ['@openpalm/guardian', '@openpalm/skeleton'], }; const packages = npmPackages[unit] || []; @@ -253,8 +253,13 @@ jobs: if: inputs.unit == 'platform' || inputs.unit == 'all' run: | bun install --frozen-lockfile + # The client purity gate greps the BUILT bundle (packages/client/tests/ + # purity.test.ts fails when build/ is absent), so build before bun run test. + bun run client:build bun run test bun run ui:check + bun run ui-kit:check + bun run client:check bun run --cwd packages/ui test:browsers bun run ui:test:unit bun run electron:test @@ -304,7 +309,8 @@ jobs: platform) for f in package.json packages/lib/package.json packages/skeleton/package.json \ packages/guardian/package.json packages/cli/package.json \ - packages/ui/package.json packages/electron/package.json \ + packages/ui/package.json packages/client/package.json \ + packages/electron/package.json \ packages/electron/admin-tools/package.json; do node scripts/set-version.mjs "$f" "$V" done @@ -314,7 +320,8 @@ jobs: all) for f in package.json packages/lib/package.json packages/skeleton/package.json \ packages/guardian/package.json packages/cli/package.json \ - packages/ui/package.json packages/electron/package.json \ + packages/ui/package.json packages/client/package.json \ + packages/electron/package.json \ packages/electron/admin-tools/package.json; do node scripts/set-version.mjs "$f" "$V" done @@ -382,7 +389,7 @@ jobs: # ── npm layer ───────────────────────────────────────────────────────────────── # skeleton + guardian: published for unit=guardian (thin-host needs both at runtime) # and unit=platform + unit=all (full coordinated release) - # lib + cli + ui: platform + all only (guardian npm pkg has no lib dep) + # lib + cli + ui + client: platform + all only (guardian npm pkg has no lib dep) # NOTE: guardian and skeleton are published independently of lib to avoid # blocking the platform regression guard. lib is platform-managed. npm-skeleton: @@ -463,6 +470,28 @@ jobs: exact-version: true dry-run: ${{ inputs.dry_run }} + # Client app (@openpalm/client): exact-pin delivered like @openpalm/ui — the + # assistant container installs @openpalm/client@ at startup + # (ui-runtime-modes-plan.md §1 exact-pin table, Phase 5 item 5). needs-build + # compiles the adapter-static bundle; @openpalm/ui-kit is inlined from the + # workspace at build time (raw-source, NEVER published). Unlike npm-ui there + # is no npm-lib ordering dependency: the client never bundles @openpalm/lib + # (§8.10 purity rule, enforced by packages/client/tests/purity.test.ts in CI). + npm-client: + name: npm @openpalm/client + needs: [compute-version, bump] + if: always() && needs.bump.result == 'success' && (inputs.unit == 'platform' || inputs.unit == 'all') + uses: ./.github/workflows/publish-npm-package.yml + secrets: inherit + with: + package-dir: packages/client + package-name: '@openpalm/client' + needs-build: true + version: ${{ needs.compute-version.outputs.new_version }} + ref: ${{ github.ref_name }} + exact-version: true + dry-run: ${{ inputs.dry_run }} + # Portal adapters: the portal image bun-installs these from npm at runtime # (data/portal/tools/package.json + bun update), so a published patch reaches # running portals without an image rebuild. Published as `src` only (no build). @@ -508,6 +537,11 @@ jobs: - uses: actions/checkout@v6 with: ref: ${{ github.ref_name }} + - name: Stamp local guardian/skeleton sources for dry-run image build + if: ${{ inputs.dry_run }} + run: | + node scripts/set-version.mjs packages/guardian/package.json "${{ needs.compute-version.outputs.new_version }}" + node scripts/set-version.mjs packages/skeleton/package.json "${{ needs.compute-version.outputs.new_version }}" - uses: docker/setup-qemu-action@v3 - uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub @@ -563,6 +597,11 @@ jobs: - uses: actions/checkout@v6 with: ref: ${{ github.ref_name }} + - name: Stamp local guardian/skeleton sources for dry-run image build + if: ${{ inputs.dry_run }} + run: | + node scripts/set-version.mjs packages/guardian/package.json "${{ needs.compute-version.outputs.new_version }}" + node scripts/set-version.mjs packages/skeleton/package.json "${{ needs.compute-version.outputs.new_version }}" - uses: docker/setup-qemu-action@v3 - uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub @@ -593,6 +632,8 @@ jobs: file: containers/guardian/Dockerfile build-args: | GUARDIAN_VERSION=${{ needs.compute-version.outputs.new_version }} + GUARDIAN_USE_LOCAL_SOURCE=${{ inputs.dry_run && 'true' || 'false' }} + SKELETON_USE_LOCAL_SOURCE=${{ inputs.dry_run && 'true' || 'false' }} platforms: ${{ inputs.dry_run && 'linux/amd64' || 'linux/amd64,linux/arm64' }} push: ${{ !inputs.dry_run }} # Provenance/SBOM attestations add extra manifests + blobs that make @@ -781,6 +822,7 @@ jobs: - npm-guardian - npm-cli - npm-ui + - npm-client - npm-discord-portal - npm-slack-portal - docker-portal diff --git a/.gitignore b/.gitignore index 58aecad53..745a320a7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ CLAUDE.md packages/ui/.svelte-kit/ packages/ui/build/ packages/ui/dist/ +packages/client/build/ packages/ui/undefined/ packages/ui/.env packages/ui/.env.local diff --git a/CHANGELOG.md b/CHANGELOG.md index 720589c02..cb65a3b2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- **New `@openpalm/client` npm package** — the unprivileged chat/connections + static app extracted from the admin UI (host/client split, #555). It joins the + platform release exactly like `@openpalm/ui`: published by `platform`/`all` + releases, always exact-pinned to the platform version. The assistant container + installs it at startup as a co-process next to OpenCode (#510), pinned via + **`OP_CLIENT_VERSION`** in `stack.env` (empty = the image's `PLATFORM_VERSION`; + never `latest` — the same contract as `OP_UI_VERSION` for the host UI), and + serves it on **`OP_CLIENT_PORT`** (default host bind `127.0.0.1:3810`, behind + the existing `OP_CLIENT_BIND_ADDRESS`/`OP_BIND_ADDRESS` loopback policy). + `docker restart` with a new `OP_CLIENT_VERSION` picks up the new client. CI now + enforces a client-bundle purity gate: the built artifact must contain no + `@openpalm/lib` and no host control-plane (`/api/host`) code. The shared + `@openpalm/ui-kit` workspace package is inlined at build time and is never + published. - The guardian thin-host entrypoint can now install and boot a configurable guardian composition package via `OP_GUARDIAN_PACKAGE` (default `@openpalm/guardian`) with an overridable boot entry `OP_GUARDIAN_ENTRY` diff --git a/bun.lock b/bun.lock index 0f627c439..58cc73544 100644 --- a/bun.lock +++ b/bun.lock @@ -15,8 +15,8 @@ "openpalm": "./bin/openpalm.js", }, "dependencies": { - "@openpalm/lib": ">=0.12.19 <1.0.0", - "@openpalm/skeleton": "0.12.18", + "@openpalm/lib": ">=0.12.52 <1.0.0", + "@openpalm/skeleton": "0.12.52", "citty": "^0.2.1", "yaml": "^2.8.0", }, @@ -25,6 +25,25 @@ "typescript": "^6.0.3", }, }, + "packages/client": { + "name": "@openpalm/client", + "version": "0.12.52", + "bin": { + "openpalm-client-serve": "bin/serve.mjs", + }, + "devDependencies": { + "@openpalm/ui-kit": "workspace:*", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.53.3", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@types/node": "^25.9.1", + "@vite-pwa/sveltekit": "^1.1.0", + "svelte": "^5.53.5", + "svelte-check": "^4.1.1", + "typescript": "^6.0.3", + "vite": "^8.0.14", + }, + }, "packages/electron": { "name": "@openpalm/electron", "version": "0.12.52", @@ -93,6 +112,7 @@ "@eslint/compat": "^2.0.2", "@eslint/js": "^10.0.1", "@openpalm/lib": "workspace:*", + "@openpalm/ui-kit": "workspace:*", "@playwright/test": "^1.58.1", "@sveltejs/adapter-node": "^5.5.4", "@sveltejs/kit": "^2.53.3", @@ -122,6 +142,20 @@ "yaml": "^2.8.0", }, }, + "packages/ui-kit": { + "name": "@openpalm/ui-kit", + "version": "0.12.52", + "devDependencies": { + "svelte": "^5.53.5", + "svelte-check": "^4.1.1", + "typescript": "^6.0.3", + "vitest": "^4.0.18", + "vitest-browser-svelte": "^2.0.2", + }, + "peerDependencies": { + "svelte": "^5.0.0", + }, + }, "portals/discord": { "name": "@openpalm/discord-portal", "version": "0.12.44", @@ -142,14 +176,190 @@ "packages": { "7zip-bin": ["7zip-bin@5.2.0", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="], + "@apideck/better-ajv-errors": ["@apideck/better-ajv-errors@0.3.7", "", { "dependencies": { "jsonpointer": "^5.0.1", "leven": "^3.1.0" }, "peerDependencies": { "ajv": ">=8" } }, "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw=="], + "@axe-core/playwright": ["@axe-core/playwright@4.11.3", "", { "dependencies": { "axe-core": "~4.11.4" }, "peerDependencies": { "playwright-core": ">= 1.0.0" } }, "sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="], + + "@babel/helper-create-regexp-features-plugin": ["@babel/helper-create-regexp-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "regexpu-core": "^6.3.1", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg=="], + + "@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.8", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "debug": "^4.4.3", "lodash.debounce": "^4.0.8", "resolve": "^1.22.11" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], + + "@babel/helper-remap-async-to-generator": ["@babel/helper-remap-async-to-generator@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-wrap-function": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og=="], + + "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.29.7", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="], + + "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helper-wrap-function": ["@babel/helper-wrap-function@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + "@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": ["@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w=="], + + "@babel/plugin-bugfix-safari-class-field-initializer-scope": ["@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ=="], + + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": ["@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ=="], + + "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": ["@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA=="], + + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": ["@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-transform-optional-chaining": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.13.0" } }, "sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw=="], + + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": ["@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw=="], + + "@babel/plugin-proposal-private-property-in-object": ["@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w=="], + + "@babel/plugin-syntax-import-assertions": ["@babel/plugin-syntax-import-assertions@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw=="], + + "@babel/plugin-syntax-import-attributes": ["@babel/plugin-syntax-import-attributes@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg=="], + + "@babel/plugin-syntax-unicode-sets-regex": ["@babel/plugin-syntax-unicode-sets-regex@7.18.6", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg=="], + + "@babel/plugin-transform-arrow-functions": ["@babel/plugin-transform-arrow-functions@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ=="], + + "@babel/plugin-transform-async-generator-functions": ["@babel/plugin-transform-async-generator-functions@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-remap-async-to-generator": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA=="], + + "@babel/plugin-transform-async-to-generator": ["@babel/plugin-transform-async-to-generator@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-remap-async-to-generator": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w=="], + + "@babel/plugin-transform-block-scoped-functions": ["@babel/plugin-transform-block-scoped-functions@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA=="], + + "@babel/plugin-transform-block-scoping": ["@babel/plugin-transform-block-scoping@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ=="], + + "@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA=="], + + "@babel/plugin-transform-class-static-block": ["@babel/plugin-transform-class-static-block@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.12.0" } }, "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A=="], + + "@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g=="], + + "@babel/plugin-transform-computed-properties": ["@babel/plugin-transform-computed-properties@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/template": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA=="], + + "@babel/plugin-transform-destructuring": ["@babel/plugin-transform-destructuring@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg=="], + + "@babel/plugin-transform-dotall-regex": ["@babel/plugin-transform-dotall-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ=="], + + "@babel/plugin-transform-duplicate-keys": ["@babel/plugin-transform-duplicate-keys@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ=="], + + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": ["@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA=="], + + "@babel/plugin-transform-dynamic-import": ["@babel/plugin-transform-dynamic-import@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg=="], + + "@babel/plugin-transform-explicit-resource-management": ["@babel/plugin-transform-explicit-resource-management@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw=="], + + "@babel/plugin-transform-exponentiation-operator": ["@babel/plugin-transform-exponentiation-operator@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ=="], + + "@babel/plugin-transform-export-namespace-from": ["@babel/plugin-transform-export-namespace-from@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA=="], + + "@babel/plugin-transform-for-of": ["@babel/plugin-transform-for-of@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ=="], + + "@babel/plugin-transform-function-name": ["@babel/plugin-transform-function-name@7.29.7", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg=="], + + "@babel/plugin-transform-json-strings": ["@babel/plugin-transform-json-strings@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg=="], + + "@babel/plugin-transform-literals": ["@babel/plugin-transform-literals@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw=="], + + "@babel/plugin-transform-logical-assignment-operators": ["@babel/plugin-transform-logical-assignment-operators@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q=="], + + "@babel/plugin-transform-member-expression-literals": ["@babel/plugin-transform-member-expression-literals@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg=="], + + "@babel/plugin-transform-modules-amd": ["@babel/plugin-transform-modules-amd@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew=="], + + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="], + + "@babel/plugin-transform-modules-systemjs": ["@babel/plugin-transform-modules-systemjs@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ=="], + + "@babel/plugin-transform-modules-umd": ["@babel/plugin-transform-modules-umd@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA=="], + + "@babel/plugin-transform-named-capturing-groups-regex": ["@babel/plugin-transform-named-capturing-groups-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ=="], + + "@babel/plugin-transform-new-target": ["@babel/plugin-transform-new-target@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A=="], + + "@babel/plugin-transform-nullish-coalescing-operator": ["@babel/plugin-transform-nullish-coalescing-operator@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg=="], + + "@babel/plugin-transform-numeric-separator": ["@babel/plugin-transform-numeric-separator@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw=="], + + "@babel/plugin-transform-object-rest-spread": ["@babel/plugin-transform-object-rest-spread@7.29.7", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7", "@babel/plugin-transform-parameters": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A=="], + + "@babel/plugin-transform-object-super": ["@babel/plugin-transform-object-super@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA=="], + + "@babel/plugin-transform-optional-catch-binding": ["@babel/plugin-transform-optional-catch-binding@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng=="], + + "@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ=="], + + "@babel/plugin-transform-parameters": ["@babel/plugin-transform-parameters@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g=="], + + "@babel/plugin-transform-private-methods": ["@babel/plugin-transform-private-methods@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug=="], + + "@babel/plugin-transform-private-property-in-object": ["@babel/plugin-transform-private-property-in-object@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA=="], + + "@babel/plugin-transform-property-literals": ["@babel/plugin-transform-property-literals@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA=="], + + "@babel/plugin-transform-regenerator": ["@babel/plugin-transform-regenerator@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw=="], + + "@babel/plugin-transform-regexp-modifiers": ["@babel/plugin-transform-regexp-modifiers@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ=="], + + "@babel/plugin-transform-reserved-words": ["@babel/plugin-transform-reserved-words@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA=="], + + "@babel/plugin-transform-shorthand-properties": ["@babel/plugin-transform-shorthand-properties@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg=="], + + "@babel/plugin-transform-spread": ["@babel/plugin-transform-spread@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/u5K1QWada7tbYNqTjMh96718g9NTwh9tfPJMsSmVsQwGT447FskV+KcfeXkXq2GWki4EM/MuTdmBec+hOuVTQ=="], + + "@babel/plugin-transform-sticky-regex": ["@babel/plugin-transform-sticky-regex@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA=="], + + "@babel/plugin-transform-template-literals": ["@babel/plugin-transform-template-literals@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA=="], + + "@babel/plugin-transform-typeof-symbol": ["@babel/plugin-transform-typeof-symbol@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A=="], + + "@babel/plugin-transform-unicode-escapes": ["@babel/plugin-transform-unicode-escapes@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA=="], + + "@babel/plugin-transform-unicode-property-regex": ["@babel/plugin-transform-unicode-property-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw=="], + + "@babel/plugin-transform-unicode-regex": ["@babel/plugin-transform-unicode-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA=="], + + "@babel/plugin-transform-unicode-sets-regex": ["@babel/plugin-transform-unicode-sets-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg=="], + + "@babel/preset-env": ["@babel/preset-env@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.29.7", "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.29.7", "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.29.7", "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.7", "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.29.7", "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.29.7", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", "@babel/plugin-syntax-import-assertions": "^7.29.7", "@babel/plugin-syntax-import-attributes": "^7.29.7", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", "@babel/plugin-transform-arrow-functions": "^7.29.7", "@babel/plugin-transform-async-generator-functions": "^7.29.7", "@babel/plugin-transform-async-to-generator": "^7.29.7", "@babel/plugin-transform-block-scoped-functions": "^7.29.7", "@babel/plugin-transform-block-scoping": "^7.29.7", "@babel/plugin-transform-class-properties": "^7.29.7", "@babel/plugin-transform-class-static-block": "^7.29.7", "@babel/plugin-transform-classes": "^7.29.7", "@babel/plugin-transform-computed-properties": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7", "@babel/plugin-transform-dotall-regex": "^7.29.7", "@babel/plugin-transform-duplicate-keys": "^7.29.7", "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.7", "@babel/plugin-transform-dynamic-import": "^7.29.7", "@babel/plugin-transform-explicit-resource-management": "^7.29.7", "@babel/plugin-transform-exponentiation-operator": "^7.29.7", "@babel/plugin-transform-export-namespace-from": "^7.29.7", "@babel/plugin-transform-for-of": "^7.29.7", "@babel/plugin-transform-function-name": "^7.29.7", "@babel/plugin-transform-json-strings": "^7.29.7", "@babel/plugin-transform-literals": "^7.29.7", "@babel/plugin-transform-logical-assignment-operators": "^7.29.7", "@babel/plugin-transform-member-expression-literals": "^7.29.7", "@babel/plugin-transform-modules-amd": "^7.29.7", "@babel/plugin-transform-modules-commonjs": "^7.29.7", "@babel/plugin-transform-modules-systemjs": "^7.29.7", "@babel/plugin-transform-modules-umd": "^7.29.7", "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.7", "@babel/plugin-transform-new-target": "^7.29.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.29.7", "@babel/plugin-transform-numeric-separator": "^7.29.7", "@babel/plugin-transform-object-rest-spread": "^7.29.7", "@babel/plugin-transform-object-super": "^7.29.7", "@babel/plugin-transform-optional-catch-binding": "^7.29.7", "@babel/plugin-transform-optional-chaining": "^7.29.7", "@babel/plugin-transform-parameters": "^7.29.7", "@babel/plugin-transform-private-methods": "^7.29.7", "@babel/plugin-transform-private-property-in-object": "^7.29.7", "@babel/plugin-transform-property-literals": "^7.29.7", "@babel/plugin-transform-regenerator": "^7.29.7", "@babel/plugin-transform-regexp-modifiers": "^7.29.7", "@babel/plugin-transform-reserved-words": "^7.29.7", "@babel/plugin-transform-shorthand-properties": "^7.29.7", "@babel/plugin-transform-spread": "^7.29.7", "@babel/plugin-transform-sticky-regex": "^7.29.7", "@babel/plugin-transform-template-literals": "^7.29.7", "@babel/plugin-transform-typeof-symbol": "^7.29.7", "@babel/plugin-transform-unicode-escapes": "^7.29.7", "@babel/plugin-transform-unicode-property-regex": "^7.29.7", "@babel/plugin-transform-unicode-regex": "^7.29.7", "@babel/plugin-transform-unicode-sets-regex": "^7.29.7", "@babel/preset-modules": "0.1.6-no-external-plugins", "babel-plugin-polyfill-corejs2": "^0.4.15", "babel-plugin-polyfill-corejs3": "^0.14.0", "babel-plugin-polyfill-regenerator": "^0.6.6", "core-js-compat": "^3.48.0", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA=="], + + "@babel/preset-modules": ["@babel/preset-modules@0.1.6-no-external-plugins", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@babel/types": "^7.4.4", "esutils": "^2.0.2" }, "peerDependencies": { "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" } }, "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], @@ -240,6 +450,8 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -248,6 +460,8 @@ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + "@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], @@ -278,6 +492,8 @@ "@openpalm/admin-tools-plugin": ["@openpalm/admin-tools-plugin@workspace:packages/electron/admin-tools"], + "@openpalm/client": ["@openpalm/client@workspace:packages/client"], + "@openpalm/discord-portal": ["@openpalm/discord-portal@workspace:portals/discord"], "@openpalm/electron": ["@openpalm/electron@workspace:packages/electron"], @@ -294,6 +510,8 @@ "@openpalm/ui": ["@openpalm/ui@workspace:packages/ui"], + "@openpalm/ui-kit": ["@openpalm/ui-kit@workspace:packages/ui-kit"], + "@oxc-project/types": ["@oxc-project/types@0.132.0", "", {}, "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ=="], "@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="], @@ -332,12 +550,18 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@rollup/plugin-babel": ["@rollup/plugin-babel@6.1.0", "", { "dependencies": { "@babel/helper-module-imports": "^7.18.6", "@rollup/pluginutils": "^5.0.1" }, "peerDependencies": { "@babel/core": "^7.0.0", "@types/babel__core": "^7.1.9", "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["@types/babel__core", "rollup"] }, "sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA=="], + "@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@29.0.2", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg=="], "@rollup/plugin-json": ["@rollup/plugin-json@6.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA=="], "@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@16.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg=="], + "@rollup/plugin-replace": ["@rollup/plugin-replace@6.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "magic-string": "^0.30.3" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA=="], + + "@rollup/plugin-terser": ["@rollup/plugin-terser@1.0.0", "", { "dependencies": { "serialize-javascript": "^7.0.3", "smob": "^1.0.0", "terser": "^5.17.4" }, "peerDependencies": { "rollup": "^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ=="], + "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.4", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="], @@ -416,6 +640,8 @@ "@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.5.4", "", { "dependencies": { "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.59.0" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-45X92CXW+2J8ZUzPv3eLlKWEzINKiiGeFWTjyER4ZN4sGgNoaoeSkCY/QYNxHpPXy71QPsctwccBo9jJs0ySPQ=="], + "@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.10", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="], + "@sveltejs/kit": ["@sveltejs/kit@2.61.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", "acorn": "^8.16.0", "cookie": "^0.6.0", "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-beYjgUux5ITbZeL0vn6gipZlsQiXF1/08C/3F+vlbDvthb/CTgYpZsYPdRIi9RxgTwRSkKIvnxyl+ViZlX4q5A=="], "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@7.1.2", "", { "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.2" }, "peerDependencies": { "svelte": "^5.46.4", "vite": "^8.0.0-beta.7 || ^8.0.0" } }, "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA=="], @@ -424,6 +650,8 @@ "@testing-library/svelte-core": ["@testing-library/svelte-core@1.0.0", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" } }, "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ=="], + "@trickfilm400/rollup-plugin-off-main-thread": ["@trickfilm400/rollup-plugin-off-main-thread@3.0.0-pre1", "", { "dependencies": { "ejs": "^3.1.10", "json5": "^2.2.3", "magic-string": "^0.30.21", "string.prototype.matchall": "^4.0.12" } }, "sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], @@ -516,6 +744,8 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.4", "", { "dependencies": { "@typescript-eslint/types": "8.59.4", "eslint-visitor-keys": "^5.0.0" } }, "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ=="], + "@vite-pwa/sveltekit": ["@vite-pwa/sveltekit@1.1.0", "", { "dependencies": { "kolorist": "^1.8.0", "tinyglobby": "^0.2.9", "vite-plugin-pwa": "^1.2.0" }, "peerDependencies": { "@sveltejs/kit": "^1.3.1 || ^2.0.1", "@vite-pwa/assets-generator": "^1.0.0" }, "optionalPeers": ["@vite-pwa/assets-generator"] }, "sha512-mMIf2tY+7Hg8jecpu/WY+Ki2ikoXy3hVmt3tOxi0K+lYYnKQrDYthuHireI0S+26Mg9BXzL7qQF1xeB5VYlYlg=="], + "@vitest/browser": ["@vitest/browser@4.1.7", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.7", "@vitest/utils": "4.1.7", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.7" } }, "sha512-N2JFGfXoEGVAut+kHeru9dD4BUMq/q5xDvBARNl0tUsly3m5KglLOu8VO/6MkDfOlgxXTycojkt6gBKsuyR+IQ=="], "@vitest/browser-playwright": ["@vitest/browser-playwright@4.1.7", "", { "dependencies": { "@vitest/browser": "4.1.7", "@vitest/mocker": "4.1.7", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "playwright": "*", "vitest": "4.1.7" } }, "sha512-OlTlJej7YN6VwV7zJJoNeaCsctF+JXpzpZ4oBHUbrQFfIq+0KW2f07rprCLh9N/zRIZ0v4Mchn1QDDmWMUhPKw=="], @@ -568,6 +798,10 @@ "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="], + "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], + + "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], @@ -580,26 +814,40 @@ "async-exit-hook": ["async-exit-hook@2.0.1", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="], + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], "at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="], + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + "axe-core": ["axe-core@4.11.4", "", {}, "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA=="], "axios": ["axios@1.16.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A=="], "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + "babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.17", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w=="], + + "babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.14.2", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.8", "core-js-compat": "^3.48.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g=="], + + "babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.8", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.8" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.42", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + "browserslist": ["browserslist@4.28.5", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001800", "electron-to-chromium": "^1.5.387", "node-releases": "^2.0.50", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ=="], + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], @@ -620,10 +868,14 @@ "cacheable-request": ["cacheable-request@7.0.4", "", { "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.0", "keyv": "^4.0.0", "lowercase-keys": "^2.0.0", "normalize-url": "^6.0.1", "responselike": "^2.0.0" } }, "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg=="], + "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "caniuse-lite": ["caniuse-lite@1.0.30001803", "", {}, "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -652,7 +904,9 @@ "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], - "commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], + "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + + "common-tags": ["common-tags@1.8.2", "", {}, "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA=="], "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="], @@ -670,6 +924,8 @@ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "core-js-compat": ["core-js-compat@3.49.0", "", { "dependencies": { "browserslist": "^4.28.1" } }, "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA=="], + "core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], @@ -682,8 +938,16 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "crypto-random-string": ["crypto-random-string@2.0.0", "", {}, "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], + + "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], + + "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], @@ -740,6 +1004,8 @@ "electron-publish": ["electron-publish@26.8.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w=="], + "electron-to-chromium": ["electron-to-chromium@1.5.388", "", {}, "sha512-Pl/aJaqOOxYxda3vcx1IKSJimwYXHDkEnGn0F+kG2EE68dDtx2uCinaS+Vih8Z91B9t8CSAbiF/HKyWcnXjhzw=="], + "electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -754,6 +1020,10 @@ "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], + "es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="], + + "es-abstract-get": ["es-abstract-get@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "es-object-atoms": "^1.1.2", "is-callable": "^1.2.7", "object-inspect": "^1.13.4" } }, "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -764,6 +1034,8 @@ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "es-to-primitive": ["es-to-primitive@1.3.4", "", { "dependencies": { "es-abstract-get": "^1.0.0", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "is-callable": "^1.2.7", "is-date-object": "^1.1.0", "is-symbol": "^1.1.1" } }, "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw=="], + "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -798,6 +1070,8 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "eta": ["eta@4.6.0", "", {}, "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], @@ -848,6 +1122,10 @@ "follow-redirects": ["follow-redirects@1.16.0", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], @@ -858,19 +1136,31 @@ "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], - "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "function.prototype.name": ["function.prototype.name@1.2.0", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2", "hasown": "^2.0.4", "is-callable": "^1.2.7", "is-document.all": "^1.0.0" } }, "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew=="], + + "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], + + "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + "get-own-enumerable-property-symbols": ["get-own-enumerable-property-symbols@3.0.2", "", {}, "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g=="], + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], - "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + + "glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -886,10 +1176,14 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], @@ -916,6 +1210,8 @@ "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "idb": ["idb@7.1.1", "", {}, "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -928,28 +1224,80 @@ "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], + + "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], + + "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], + + "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], + + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], + + "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], + + "is-document.all": ["is-document.all@1.0.0", "", { "dependencies": { "call-bound": "^1.0.4" } }, "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g=="], + "is-electron": ["is-electron@2.2.2", "", {}, "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], + "is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="], + "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], + + "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + + "is-obj": ["is-obj@1.0.1", "", {}, "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "is-regexp": ["is-regexp@1.0.0", "", {}, "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA=="], + + "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], + + "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], + + "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], + + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], + + "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], + + "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], + + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + "isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], "isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], @@ -960,6 +1308,8 @@ "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + "jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], + "jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="], "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], @@ -970,6 +1320,8 @@ "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -984,6 +1336,8 @@ "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], @@ -996,10 +1350,14 @@ "known-css-properties": ["known-css-properties@0.37.0", "", {}, "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="], + "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], "lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="], + "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -1036,6 +1394,8 @@ "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], @@ -1052,6 +1412,8 @@ "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="], + "lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="], + "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], @@ -1122,6 +1484,8 @@ "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + "node-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="], + "nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="], "normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="], @@ -1132,6 +1496,8 @@ "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], @@ -1142,6 +1508,8 @@ "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], + "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], @@ -1156,6 +1524,8 @@ "p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], @@ -1166,6 +1536,8 @@ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -1188,6 +1560,8 @@ "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], "postcss-load-config": ["postcss-load-config@3.1.4", "", { "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg=="], @@ -1206,6 +1580,8 @@ "prettier-plugin-svelte": ["prettier-plugin-svelte@4.0.1", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^5.0.0" } }, "sha512-oDVmtKi+M8bJeUoMfPvulUqZYcuXrs5AmhhLYPKtBeg6hcpMdx7UYYisVCqEaLQuKtiPSYFpotfwp4cZK3D4xw=="], + "pretty-bytes": ["pretty-bytes@6.1.1", "", {}, "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ=="], + "proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], @@ -1238,6 +1614,20 @@ "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], + + "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], + + "regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="], + + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + + "regexpu-core": ["regexpu-core@6.4.0", "", { "dependencies": { "regenerate": "^1.4.2", "regenerate-unicode-properties": "^10.2.2", "regjsgen": "^0.8.0", "regjsparser": "^0.13.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.2.1" } }, "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA=="], + + "regjsgen": ["regjsgen@0.8.0", "", {}, "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q=="], + + "regjsparser": ["regjsparser@0.13.2", "", { "dependencies": { "jsesc": "~3.1.0" }, "bin": { "regjsparser": "bin/parser" } }, "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -1264,8 +1654,14 @@ "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], + "safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], + + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "sanitize-filename": ["sanitize-filename@1.6.4", "", { "dependencies": { "truncate-utf8-bytes": "^1.0.0" } }, "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg=="], @@ -1280,10 +1676,18 @@ "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], + "serialize-javascript": ["serialize-javascript@7.0.7", "", {}, "sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g=="], + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], "set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], + + "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -1310,7 +1714,9 @@ "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], - "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "smob": ["smob@1.6.2", "", {}, "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw=="], + + "source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -1326,10 +1732,24 @@ "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], + + "string.prototype.trim": ["string.prototype.trim@1.2.11", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-object-atoms": "^1.1.2", "has-property-descriptors": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w=="], + + "string.prototype.trimend": ["string.prototype.trimend@1.0.10", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.2" } }, "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw=="], + + "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + + "stringify-object": ["stringify-object@3.3.0", "", { "dependencies": { "get-own-enumerable-property-symbols": "^3.0.0", "is-obj": "^1.0.1", "is-regexp": "^1.0.0" } }, "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw=="], + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-comments": ["strip-comments@2.0.1", "", {}, "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw=="], + "sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="], "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -1346,8 +1766,14 @@ "temp": ["temp@0.9.4", "", { "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" } }, "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA=="], + "temp-dir": ["temp-dir@2.0.0", "", {}, "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg=="], + "temp-file": ["temp-file@3.4.0", "", { "dependencies": { "async-exit-hook": "^2.0.1", "fs-extra": "^10.0.0" } }, "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg=="], + "tempy": ["tempy@0.6.0", "", { "dependencies": { "is-stream": "^2.0.0", "temp-dir": "^2.0.0", "type-fest": "^0.16.0", "unique-string": "^2.0.0" } }, "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw=="], + + "terser": ["terser@5.48.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q=="], + "tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="], "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], @@ -1368,6 +1794,8 @@ "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + "tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="], + "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], @@ -1380,24 +1808,48 @@ "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "type-fest": ["type-fest@0.16.0", "", {}, "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg=="], "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], + + "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], + + "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], + + "typed-array-length": ["typed-array-length@1.0.8", "", { "dependencies": { "call-bind": "^1.0.9", "for-each": "^0.3.5", "gopd": "^1.2.0", "is-typed-array": "^1.1.15", "possible-typed-array-names": "^1.1.0", "reflect.getprototypeof": "^1.0.10" } }, "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g=="], + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "typescript-eslint": ["typescript-eslint@8.59.4", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.4", "@typescript-eslint/parser": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/utils": "8.59.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ=="], "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], + "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + "undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="], "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="], + + "unicode-match-property-ecmascript": ["unicode-match-property-ecmascript@2.0.0", "", { "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" } }, "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q=="], + + "unicode-match-property-value-ecmascript": ["unicode-match-property-value-ecmascript@2.2.1", "", {}, "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg=="], + + "unicode-property-aliases-ecmascript": ["unicode-property-aliases-ecmascript@2.2.0", "", {}, "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ=="], + + "unique-string": ["unique-string@2.0.0", "", { "dependencies": { "crypto-random-string": "^2.0.0" } }, "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg=="], + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "upath": ["upath@1.2.0", "", {}, "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="], @@ -1414,18 +1866,64 @@ "vite-plugin-devtools-json": ["vite-plugin-devtools-json@1.0.0", "", { "dependencies": { "uuid": "^11.1.0" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-MobvwqX76Vqt/O4AbnNMNWoXWGrKUqZbphCUle/J2KXH82yKQiunOeKnz/nqEPosPsoWWPP9FtNuPBSYpiiwkw=="], + "vite-plugin-pwa": ["vite-plugin-pwa@1.3.0", "", { "dependencies": { "debug": "^4.3.6", "pretty-bytes": "^6.1.1", "tinyglobby": "^0.2.10", "workbox-build": "^7.4.1", "workbox-window": "^7.4.1" }, "peerDependencies": { "@vite-pwa/assets-generator": "^1.0.0", "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@vite-pwa/assets-generator"] }, "sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A=="], + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], "vitest": ["vitest@4.1.7", "", { "dependencies": { "@vitest/expect": "4.1.7", "@vitest/mocker": "4.1.7", "@vitest/pretty-format": "4.1.7", "@vitest/runner": "4.1.7", "@vitest/snapshot": "4.1.7", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.7", "@vitest/browser-preview": "4.1.7", "@vitest/browser-webdriverio": "4.1.7", "@vitest/coverage-istanbul": "4.1.7", "@vitest/coverage-v8": "4.1.7", "@vitest/ui": "4.1.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA=="], "vitest-browser-svelte": ["vitest-browser-svelte@2.1.1", "", { "dependencies": { "@testing-library/svelte-core": "^1.0.0" }, "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", "vitest": "^4.0.0" } }, "sha512-qbunYRSm+N92r9bfTkdDTpBZESLmp4QFz2SluV3n/x8U7ysosfeXYJZ4vXbJ0Y0LzoqqDnV5LHprmFgn4Eo+Ug=="], + "webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="], + + "whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="], + "which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], + "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], + + "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], + + "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], + + "which-typed-array": ["which-typed-array@1.1.22", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "workbox-background-sync": ["workbox-background-sync@7.4.1", "", { "dependencies": { "idb": "^7.0.1", "workbox-core": "7.4.1" } }, "sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g=="], + + "workbox-broadcast-update": ["workbox-broadcast-update@7.4.1", "", { "dependencies": { "workbox-core": "7.4.1" } }, "sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A=="], + + "workbox-build": ["workbox-build@7.4.1", "", { "dependencies": { "@apideck/better-ajv-errors": "^0.3.1", "@babel/core": "^7.24.4", "@babel/preset-env": "^7.11.0", "@babel/runtime": "^7.11.2", "@rollup/plugin-babel": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-replace": "^6.0.3", "@rollup/plugin-terser": "^1.0.0", "@trickfilm400/rollup-plugin-off-main-thread": "^3.0.0-pre1", "ajv": "^8.6.0", "common-tags": "^1.8.0", "eta": "^4.5.1", "fast-json-stable-stringify": "^2.1.0", "fs-extra": "^9.0.1", "glob": "^11.0.1", "pretty-bytes": "^5.3.0", "rollup": "^4.53.3", "source-map": "^0.8.0-beta.0", "stringify-object": "^3.3.0", "strip-comments": "^2.0.1", "tempy": "^0.6.0", "upath": "^1.2.0", "workbox-background-sync": "7.4.1", "workbox-broadcast-update": "7.4.1", "workbox-cacheable-response": "7.4.1", "workbox-core": "7.4.1", "workbox-expiration": "7.4.1", "workbox-google-analytics": "7.4.1", "workbox-navigation-preload": "7.4.1", "workbox-precaching": "7.4.1", "workbox-range-requests": "7.4.1", "workbox-recipes": "7.4.1", "workbox-routing": "7.4.1", "workbox-strategies": "7.4.1", "workbox-streams": "7.4.1", "workbox-sw": "7.4.1", "workbox-window": "7.4.1" } }, "sha512-SDhxIvEAde9Gy/5w4Yo1Jh/M49Z0qE3q0oteyE8zGq0DScxFqVBcCtIXFuLtmtxRQZCMbf0prco4VyEu3KBQuw=="], + + "workbox-cacheable-response": ["workbox-cacheable-response@7.4.1", "", { "dependencies": { "workbox-core": "7.4.1" } }, "sha512-8xaFoJdDc2OjrlbbL3gEeBO1WKcMwRqwLRupgqahYXu75yXajPLuwrbXMrIGZuWYXrQwk0xDjOxZ/ujCy/oJYw=="], + + "workbox-core": ["workbox-core@7.4.1", "", {}, "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ=="], + + "workbox-expiration": ["workbox-expiration@7.4.1", "", { "dependencies": { "idb": "^7.0.1", "workbox-core": "7.4.1" } }, "sha512-lRKUF7b+OGbeXkQk1s6MHXOa3d7Xxf7Of31W6c6hCfipfIyrtdWZ89stq21AHZMaoG7VNFoHply4Ox+rU31TWg=="], + + "workbox-google-analytics": ["workbox-google-analytics@7.4.1", "", { "dependencies": { "workbox-background-sync": "7.4.1", "workbox-core": "7.4.1", "workbox-routing": "7.4.1", "workbox-strategies": "7.4.1" } }, "sha512-Mks1JwLEt++ZAkF6sS1OpSh9RtAMIsiDgRpK+codiHGIPXeaUOgi4cPc3GFadUl8V5QPeypEk8Oxgl3HlwVzHw=="], + + "workbox-navigation-preload": ["workbox-navigation-preload@7.4.1", "", { "dependencies": { "workbox-core": "7.4.1" } }, "sha512-C4KVsjPcYKJOhr631AxR9XoG2rLF3QiTk5aMv36MXOjtWvm8axwNFAtKUPGsWUwLXXAMgYM1En7fsvndaXeXRQ=="], + + "workbox-precaching": ["workbox-precaching@7.4.1", "", { "dependencies": { "workbox-core": "7.4.1", "workbox-routing": "7.4.1", "workbox-strategies": "7.4.1" } }, "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q=="], + + "workbox-range-requests": ["workbox-range-requests@7.4.1", "", { "dependencies": { "workbox-core": "7.4.1" } }, "sha512-7i2oxAUE82gHdAJBCAQ04JzNOdRPqzuOzGfoUyJpFSmeqBNYGPrAH8GPoPjUQTfp+NycwrD2H68VtuF8qxv0vQ=="], + + "workbox-recipes": ["workbox-recipes@7.4.1", "", { "dependencies": { "workbox-cacheable-response": "7.4.1", "workbox-core": "7.4.1", "workbox-expiration": "7.4.1", "workbox-precaching": "7.4.1", "workbox-routing": "7.4.1", "workbox-strategies": "7.4.1" } }, "sha512-gnbVfmV4/TtmQaM4x9AtuXhcdstJsep3XMVeztOrQVPT+R6+6DeBjGTCQ7fFCXm+4GEHUA5VEBTyi5+4gWGeog=="], + + "workbox-routing": ["workbox-routing@7.4.1", "", { "dependencies": { "workbox-core": "7.4.1" } }, "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA=="], + + "workbox-strategies": ["workbox-strategies@7.4.1", "", { "dependencies": { "workbox-core": "7.4.1" } }, "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ=="], + + "workbox-streams": ["workbox-streams@7.4.1", "", { "dependencies": { "workbox-core": "7.4.1", "workbox-routing": "7.4.1" } }, "sha512-HWWtraKUbJknd9kgqGcpQ3G114HOPYvqs8HaJMDs2ebLNAimDkVDaWfAXE6Ybl+m8U6KsCE6pWyLYuigWmnAXw=="], + + "workbox-sw": ["workbox-sw@7.4.1", "", {}, "sha512-fez5f2DUlDJWTFYkCWQpY10N8gtztd849NswCbVFk0QlcSM4HT5A8x4g4ii650yem4I8tHY0R7JZahwp3ltIPw=="], + + "workbox-window": ["workbox-window@7.4.1", "", { "dependencies": { "@types/trusted-types": "^2.0.2", "workbox-core": "7.4.1" } }, "sha512-notZDH2u8VXaqyuD7xaqIfEFi6SRM4SUSd7ewe9PDsVqADuepxX2ZMY3uvuZGxzY5ZOsGC/vD3A/3smFtJt4/A=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], @@ -1454,6 +1952,58 @@ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "@babel/core/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/core/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/generator/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/generator/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/helper-annotate-as-pure/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-create-regexp-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-member-expression-to-functions/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/helper-module-imports/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/helper-module-transforms/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-optimise-call-expression/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/helper-skip-transparent-expression-wrappers/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/helper-wrap-function/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/helpers/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/plugin-transform-modules-systemjs/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/preset-env/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/preset-modules/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/template/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/template/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/traverse/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/traverse/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + "@develar/schema-utils/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], @@ -1462,6 +2012,10 @@ "@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], + "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], + + "@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "@electron/asar/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], @@ -1502,6 +2056,8 @@ "axios/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], @@ -1528,9 +2084,11 @@ "filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "function.prototype.name/hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], "lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], @@ -1540,10 +2098,12 @@ "node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], - "openpalm/@openpalm/skeleton": ["@openpalm/skeleton@0.12.18", "", {}, "sha512-s80tOj6FEDjAwoJOJ6siAdxrqIdgm2pLp4PL4WuWkNef1gT1/fxiPmR6ayTHr5K4mmO6ZHrWjrRtOzZJxQptOQ=="], - "p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "path-scurry/lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], + + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "postcss-load-config/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], "postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], @@ -1554,9 +2114,13 @@ "raw-body/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "rollup/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + + "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "svelte-eslint-parser/eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], @@ -1568,7 +2132,59 @@ "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "workbox-build/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], + + "workbox-build/pretty-bytes": ["pretty-bytes@5.6.0", "", {}, "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="], + + "@babel/core/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/core/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/generator/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-annotate-as-pure/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-annotate-as-pure/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "@babel/helper-member-expression-to-functions/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-member-expression-to-functions/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-module-imports/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-module-imports/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-optimise-call-expression/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-optimise-call-expression/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-skip-transparent-expression-wrappers/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-skip-transparent-expression-wrappers/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-wrap-function/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-wrap-function/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helpers/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helpers/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/preset-modules/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/preset-modules/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/template/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/template/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], "@develar/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], @@ -1604,10 +2220,10 @@ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "glob/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], - "node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], + "rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "@electron/asar/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "@electron/universal/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -1620,6 +2236,8 @@ "filelist/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + + "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], } } diff --git a/compose.dev.yml b/compose.dev.yml index 5aa6839f7..f3b26252b 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -5,13 +5,16 @@ # # Manual equivalent (from project root): # docker compose --project-directory . \ -# -f .openpalm/config/stack/core.compose.yml \ +# -f .dev/system/stack/core.compose.yml \ +# -f .dev/system/stack/services.compose.yml \ +# -f .dev/system/stack/portals.compose.yml \ +# -f .dev/config/stack/custom.compose.yml \ # -f compose.dev.yml \ # --env-file .dev/knowledge/env/stack.env \ # up --build -d # -# The --project-directory . flag is REQUIRED when the core compose file lives -# in a different directory (.openpalm/config/stack/ or .dev/config/stack/). +# The --project-directory . flag is REQUIRED when the managed compose files live +# in a different directory (.openpalm/system/stack/ or .dev/system/stack/). # Without it, Docker Compose resolves build contexts relative to the first -f # file's directory, which breaks builds. @@ -28,6 +31,8 @@ services: # GUARDIAN_VERSION; a bare `bun run dev:build` falls back to the latest # published guardian, which keeps the guard satisfied. GUARDIAN_VERSION: ${GUARDIAN_VERSION:-latest} + GUARDIAN_USE_LOCAL_SOURCE: ${GUARDIAN_USE_LOCAL_SOURCE:-false} + SKELETON_USE_LOCAL_SOURCE: ${SKELETON_USE_LOCAL_SOURCE:-false} image: ${OP_IMAGE_NAMESPACE:-openpalm}/guardian:dev # Guardian is intentionally NOT host-published. It is reachable only on # the portal_net / assistant_net networks as `http://guardian:8080`. diff --git a/containers/assistant/Dockerfile b/containers/assistant/Dockerfile index 06109ce0f..751e33957 100644 --- a/containers/assistant/Dockerfile +++ b/containers/assistant/Dockerfile @@ -48,8 +48,8 @@ RUN npm install --omit=dev --no-fund --no-audit \ FROM node:22-trixie-slim ARG BUN_VERSION=bun-v1.3.10 -# Platform version — drives OP_UI_VERSION and OP_SKELETON_VERSION defaults in -# the entrypoint when no per-component env var override is set. +# Platform version — drives OP_CLIENT_VERSION and OP_SKELETON_VERSION defaults +# in the entrypoint when no per-component env var override is set. ARG PLATFORM_VERSION # Re-export for runtime introspection. @@ -170,9 +170,9 @@ RUN set -e; \ # layer. The tools tree is bind-mounted (host-owned) at runtime, so its baked # mode is moot. Secrets (knowledge/secrets, 0600) are a separate bind-mount tree. RUN mkdir -p /home/opencode/.cache /home/opencode/.local/bin /work /stash /opt/akm/cache /opt/akm/data /opt/persistent/bin \ - && mkdir -p /opt/openpalm/ui /opt/openpalm/skeleton /opt/openpalm/tools \ - && chown -R node:node /home/opencode /work /stash /opt/akm /opt/persistent /opt/openpalm/ui /opt/openpalm/skeleton \ - && chmod -R a+rwX /home/opencode /work /stash /opt/akm /opt/persistent /opt/openpalm/ui /opt/openpalm/skeleton + && mkdir -p /opt/openpalm/client /opt/openpalm/skeleton /opt/openpalm/tools \ + && chown -R node:node /home/opencode /work /stash /opt/akm /opt/persistent /opt/openpalm/client /opt/openpalm/skeleton \ + && chmod -R a+rwX /home/opencode /work /stash /opt/akm /opt/persistent /opt/openpalm/client /opt/openpalm/skeleton COPY containers/assistant/entrypoint.sh /usr/local/bin/opencode-entrypoint.sh RUN chmod +x /usr/local/bin/opencode-entrypoint.sh diff --git a/containers/assistant/entrypoint.sh b/containers/assistant/entrypoint.sh index 3c65b9a35..59c033ee2 100644 --- a/containers/assistant/entrypoint.sh +++ b/containers/assistant/entrypoint.sh @@ -63,14 +63,14 @@ maybe_source_akm_user_env() { install_runtime_artifacts() { # ── Exact-pinned npm artifacts ────────────────────────────────────────────── - # UI and skeleton versions come from OP_*_VERSION env overrides, then fall - # back to PLATFORM_VERSION (set at image build time via ARG). Hard error if - # neither is set — no 'latest' fallback for exact-pinned components. - local ui_version="${OP_UI_VERSION:-${PLATFORM_VERSION:-}}" + # Client and skeleton versions come from OP_*_VERSION env overrides, then + # fall back to PLATFORM_VERSION (set at image build time via ARG). Hard error + # if neither is set — no 'latest' fallback for exact-pinned components. + local client_version="${OP_CLIENT_VERSION:-${PLATFORM_VERSION:-}}" local skeleton_version="${OP_SKELETON_VERSION:-${PLATFORM_VERSION:-}}" - if [ -z "$ui_version" ]; then - echo "ERROR: set OP_UI_VERSION or PLATFORM_VERSION to install @openpalm/ui" >&2 + if [ -z "$client_version" ]; then + echo "ERROR: set OP_CLIENT_VERSION or PLATFORM_VERSION to install @openpalm/client" >&2 exit 1 fi if [ -z "$skeleton_version" ]; then @@ -85,17 +85,22 @@ install_runtime_artifacts() { local npm_cache_dir="/home/opencode/.cache/openpalm-npm" local bun_cache_dir="/home/opencode/.cache/bun/install" + # Existing assistant-artifacts named volumes shadow Dockerfile-created paths. + # Older images only created /opt/openpalm/{ui,skeleton}; create the current + # prefixes here so upgrades can install @openpalm/client as the node user. + mkdir -p /opt/openpalm/client /opt/openpalm/skeleton + # `grep -v` exits 1 when npm produced only warnings (or nothing), so the # pipeline's own exit code can't distinguish "npm failed" from "no output". # Capture npm's exit via PIPESTATUS and surface real failures — a silent - # EACCES here would leave the stack serving stale ui/skeleton forever. + # EACCES here would leave the stack serving stale client/skeleton forever. local npm_rc - echo "entrypoint: installing @openpalm/ui@${ui_version}..." >&2 + echo "entrypoint: installing @openpalm/client@${client_version}..." >&2 npm_rc=0 - npm_config_cache="$npm_cache_dir" npm install --prefix /opt/openpalm/ui "@openpalm/ui@${ui_version}" \ + npm_config_cache="$npm_cache_dir" npm install --prefix /opt/openpalm/client "@openpalm/client@${client_version}" \ --omit=dev --prefer-offline --no-fund --no-audit 2>&1 | grep -v "^npm warn" || npm_rc="${PIPESTATUS[0]}" if [ "$npm_rc" != "0" ]; then - echo "ERROR: @openpalm/ui@${ui_version} install failed (exit ${npm_rc}); continuing with the existing artifact if present" >&2 + echo "ERROR: @openpalm/client@${client_version} install failed (exit ${npm_rc}); continuing with the existing artifact if present" >&2 fi echo "entrypoint: installing @openpalm/skeleton@${skeleton_version}..." >&2 @@ -125,26 +130,57 @@ install_runtime_artifacts() { fi } -start_ui() { - local ui_build="/opt/openpalm/ui/node_modules/@openpalm/ui/build/index.js" - if [ ! -f "$ui_build" ]; then - echo "entrypoint: @openpalm/ui build not found — UI co-process skipped" >&2 +start_client() { + # Static chat client (@openpalm/client, plan §6.9 Slice A). The co-process + # only serves bytes — the BROWSER talks to OpenCode directly at the + # host-published assistant URL, so there is no server-side API base URL to + # wire into this process (the old adapter-node co-process env plumbing, and + # its wiring bug, are gone with it). + local client_pkg="/opt/openpalm/client/node_modules/@openpalm/client" + local serve_script="${client_pkg}/bin/serve.mjs" + local client_build="${client_pkg}/build" + if [ ! -f "$serve_script" ] || [ ! -f "${client_build}/index.html" ]; then + echo "entrypoint: @openpalm/client build not found — client co-process skipped" >&2 return 0 fi - local ui_port="${OP_UI_PORT:-3000}" - echo "entrypoint: starting UI co-process on port ${ui_port}..." >&2 - - local ui_cmd=( - node "$ui_build" - ) - - OPENCODE_API_URL="http://127.0.0.1:${PORT}" \ - PORT="$ui_port" \ - ORIGIN="${OP_UI_ORIGIN:-http://localhost:${ui_port}}" \ - "${ui_cmd[@]}" & - - echo "entrypoint: UI co-process PID $! started" >&2 + # Write runtime-config.json beside the build BEFORE serving (serve.mjs looks + # for it next to the build dir). It seeds the browser's connection store with + # ONE locked default connection: the assistant's OpenCode as published on the + # HOST — compose maps ${OP_ASSISTANT_PORT:-3800} -> in-container 4096, and + # the in-container :4096 would be unreachable from a browser. Non-default + # topologies override the full URL via OP_CLIENT_DEFAULT_ASSISTANT_URL. + # JSON is emitted via node (present in the base image) so an unusual URL + # value can never produce a malformed file. + local assistant_url="${OP_CLIENT_DEFAULT_ASSISTANT_URL:-http://127.0.0.1:${OP_ASSISTANT_PORT:-3800}}" + node -e ' + const fs = require("fs"); + const [file, url] = process.argv.slice(1); + const config = { + connections: [ + { + id: "assistant-container-opencode", + label: "This assistant", + kind: "local-opencode", + url, + auth: { mode: "none" }, + isDefault: true, + locked: true, + }, + ], + }; + fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n"); + ' "${client_pkg}/runtime-config.json" "$assistant_url" \ + || echo "warning: could not write runtime-config.json; client starts with no default connection" >&2 + + local client_port="${OP_CLIENT_PORT:-3000}" + echo "entrypoint: starting client co-process on port ${client_port}..." >&2 + + # Bind 0.0.0.0 INSIDE the container only: host exposure is governed by the + # compose port mapping, which defaults to loopback (OP_BIND_ADDRESS policy). + node "$serve_script" --host 0.0.0.0 --port "$client_port" --dir "$client_build" & + + echo "entrypoint: client co-process PID $! started" >&2 } seed_default_agents_md() { @@ -353,6 +389,49 @@ start_opencode() { # --log-level sets verbosity (override via OPENCODE_LOG_LEVEL). local cmd=(opencode web --hostname 0.0.0.0 --port "$PORT" --print-logs --log-level "${OPENCODE_LOG_LEVEL:-INFO}") + # Browser clients are served from separate loopback origins and call OpenCode + # directly. Allow the shipped origins by default, and let operators add exact + # comma-separated origins for custom reverse-proxy/client deployments. + local client_host_port="${OP_CLIENT_HOST_PORT:-${OP_CLIENT_PORT:-3810}}" + local host_client_port="${OP_HOST_CLIENT_PORT:-3890}" + local client_bind_address="${OP_CLIENT_BIND_ADDRESS:-${OP_BIND_ADDRESS:-127.0.0.1}}" + local assistant_bind_address="${OP_ASSISTANT_BIND_ADDRESS:-${OP_BIND_ADDRESS:-127.0.0.1}}" + local cors_origins=( + "http://127.0.0.1:${client_host_port}" + "http://localhost:${client_host_port}" + "http://127.0.0.1:${host_client_port}" + "http://localhost:${host_client_port}" + ) + if [ "$client_bind_address" != "127.0.0.1" ] && [ "$client_bind_address" != "localhost" ] \ + && [ "$assistant_bind_address" != "127.0.0.1" ] && [ "$assistant_bind_address" != "localhost" ]; then + if [ "$client_bind_address" = "0.0.0.0" ] || [ "$client_bind_address" = "::" ]; then + # With wildcard binds the real browser Origin is the operator-chosen LAN + # hostname/IP, which the container cannot know. The assistant itself is + # already explicitly LAN-exposed in this mode, so allow browser origins. + cors_origins+=("*") + else + cors_origins+=("http://${client_bind_address}:${client_host_port}") + fi + fi + if [ -n "${OP_CLIENT_CORS_ALLOWED_ORIGINS:-}" ]; then + local old_ifs="$IFS" + IFS=',' + read -ra extra_origins <<< "${OP_CLIENT_CORS_ALLOWED_ORIGINS}" + IFS="$old_ifs" + local origin + for origin in "${extra_origins[@]}"; do + origin="${origin#${origin%%[![:space:]]*}}" + origin="${origin%${origin##*[![:space:]]}}" + if [ -n "$origin" ]; then + cors_origins+=("$origin") + fi + done + fi + local origin + for origin in "${cors_origins[@]}"; do + cmd+=(--cors "$origin") + done + exec "${cmd[@]}" } @@ -364,5 +443,5 @@ seed_default_agents_md run_akm_schema_migration persist_akm_stash_dir_fallback start_cron_and_sync_tasks -start_ui +start_client start_opencode diff --git a/containers/guardian/Dockerfile b/containers/guardian/Dockerfile index 979f77fe4..2c06b7b17 100644 --- a/containers/guardian/Dockerfile +++ b/containers/guardian/Dockerfile @@ -27,12 +27,20 @@ RUN chmod +x /entrypoint.sh # so the baked install actually survives under the real stack. ARG GUARDIAN_VERSION ENV GUARDIAN_VERSION=${GUARDIAN_VERSION} +ARG GUARDIAN_USE_LOCAL_SOURCE=false +ARG SKELETON_USE_LOCAL_SOURCE=false ENV HOME=/opt/openpalm/guardian \ PORT=8080 \ OPENCODE_CONFIG_DIR=/etc/opencode \ PATH="/opt/openpalm/tools/node_modules/.bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +# Optional dry-run/smoke path: copy the local guardian/skeleton workspaces into +# the build context so callers can bake unpublished source at the target version +# without needing it to exist on npm yet. +COPY packages/guardian /opt/openpalm/local-src/guardian +COPY packages/skeleton /opt/openpalm/local-src/skeleton + # Bake default tool packages at /opt/openpalm/tools. In compose, # OP_HOME/data/guardian/tools is bind-mounted over this path so operators # can edit package.json directly. @@ -47,11 +55,23 @@ COPY containers/guardian/tools/package.json /opt/openpalm/tools/package.json # uid. This applies ONLY to these app/cache trees; the secret tree # (knowledge/secrets, 0600) is a bind mount and is never touched here. RUN test -n "$GUARDIAN_VERSION" \ - && mkdir -p /opt/openpalm/guardian/.local/share/opencode /opt/openpalm/guardian/.local/state/opencode /opt/openpalm/guardian-pkg /opt/openpalm/skeleton \ - && (cd /opt/openpalm/guardian-pkg && bun add "@openpalm/guardian@${GUARDIAN_VERSION}" --production) \ - && (cd /opt/openpalm/skeleton && bun add "@openpalm/skeleton@${GUARDIAN_VERSION}" --production) \ + && guardian_spec="@openpalm/guardian@${GUARDIAN_VERSION}" \ + && skeleton_spec="@openpalm/skeleton@${GUARDIAN_VERSION}" \ + && mkdir -p /opt/openpalm/local-artifacts \ + && if [ "$GUARDIAN_USE_LOCAL_SOURCE" = "true" ]; then \ + (cd /opt/openpalm/local-src/guardian && bun pm pack --destination /opt/openpalm/local-artifacts --quiet); \ + guardian_spec="$(find /opt/openpalm/local-artifacts -maxdepth 1 -name 'openpalm-guardian-*.tgz' | head -n 1)"; \ + fi \ + && if [ "$SKELETON_USE_LOCAL_SOURCE" = "true" ]; then \ + (cd /opt/openpalm/local-src/skeleton && bun pm pack --destination /opt/openpalm/local-artifacts --quiet); \ + skeleton_spec="$(find /opt/openpalm/local-artifacts -maxdepth 1 -name 'openpalm-skeleton-*.tgz' | head -n 1)"; \ + fi \ + && mkdir -p /opt/openpalm /opt/openpalm/guardian/.local/share/opencode /opt/openpalm/guardian/.local/state/opencode /opt/openpalm/guardian-pkg /opt/openpalm/skeleton \ + && (cd /opt/openpalm/guardian-pkg && bun add "$guardian_spec" --production) \ + && (cd /opt/openpalm/skeleton && bun add "$skeleton_spec" --production) \ && bun install --cwd /opt/openpalm/tools --production \ - && chmod -R a+rwX /opt/openpalm/guardian /opt/openpalm/guardian-pkg /opt/openpalm/skeleton /opt/openpalm/tools + && rm -rf /opt/openpalm/local-src /opt/openpalm/local-artifacts \ + && chmod -R a+rwX /opt/openpalm /opt/openpalm/guardian /opt/openpalm/guardian-pkg /opt/openpalm/skeleton /opt/openpalm/tools EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \ diff --git a/docs/README.md b/docs/README.md index ce50ba348..f02a61d3c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,6 +32,8 @@ Repo layout convention: | Document | Description | |---|---| | [release-management.md](operations/release-management.md) | Authoritative release guide: platform release boundaries, image/package versioning, and beta→stable cutover | +| [release-rc-runbook.md](operations/release-rc-runbook.md) | Ordered RC release procedure with merge gates, commands, evidence capture, and post-publish verification | +| [unit-all-rc-checklist.md](operations/unit-all-rc-checklist.md) | Coordinated `unit=all` release-candidate worksheet with commands, pass criteria, and evidence capture | | [manual-compose-runbook.md](operations/manual-compose-runbook.md) | Step-by-step manual host configuration (no scripts) | | [manual-headless-install.md](operations/manual-headless-install.md) | Hand-built install disk contract (tokens, auth.json, setup stamp) + scripted `openpalm install --file` | | [diagnostic-playbook.md](operations/diagnostic-playbook.md) | Layer-by-layer debugging workflow for UI, admin API, OpenCode, and container/config issues | 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/` diff --git a/docs/managing-openpalm.md b/docs/managing-openpalm.md index b34836987..31871bf08 100644 --- a/docs/managing-openpalm.md +++ b/docs/managing-openpalm.md @@ -114,15 +114,15 @@ Current shipped network model: ### Enable/disable an addon -Addons are managed via `/admin/addons` routes. Example: +Addons are managed via `/api/host/addons` routes. Example: ```bash # Authenticate first to obtain an op_session cookie (Phase 2+). -curl -c cookies.txt -X POST http://localhost:3880/admin/auth/login \ +curl -c cookies.txt -X POST http://localhost:3880/api/auth/login \ -H "Content-Type: application/json" \ -d "{\"password\":\"$OP_UI_LOGIN_PASSWORD\"}" -curl -b cookies.txt -X POST http://localhost:3880/admin/addons/chat \ +curl -b cookies.txt -X POST http://localhost:3880/api/host/addons/chat \ -H "Content-Type: application/json" \ -d '{"enabled":true}' ``` @@ -428,13 +428,13 @@ ls ~/.openpalm/data/admin-opencode/log/ # Electron-spawned OpenCode **Check container status:** ```bash docker compose ps -# Or via API (requires op_session cookie from /admin/auth/login): -curl -b cookies.txt http://localhost:3880/admin/containers/list +# Or via API (requires op_session cookie from /api/auth/login): +curl -b cookies.txt http://localhost:3880/api/host/containers/list ``` **Pull latest images and recreate containers:** ```bash -curl -b cookies.txt -X POST http://localhost:3880/admin/containers/pull +curl -b cookies.txt -X POST http://localhost:3880/api/host/containers/pull ``` This runs `docker compose pull` followed by `docker compose up` to recreate diff --git a/docs/operations/manual-compose-runbook.md b/docs/operations/manual-compose-runbook.md index 635a480b9..4a38b9fd7 100644 --- a/docs/operations/manual-compose-runbook.md +++ b/docs/operations/manual-compose-runbook.md @@ -1,9 +1,9 @@ # Manual Compose Runbook This runbook is for operators who want to manage their OpenPalm stack directly -using `docker compose` without the CLI or admin tooling. The generated -`$OP_HOME/run.sh` is the operator-facing entrypoint; it reproduces the live -compose invocation used by the stack. +using `docker compose` without the CLI or admin tooling. The live compose +assembly is the managed file set under `$OP_HOME/system/stack/` plus the +user-owned overlay at `$OP_HOME/config/stack/custom.compose.yml`. --- @@ -27,9 +27,9 @@ variable). The relevant files for running the stack are: | Path | Purpose | |---|---| -| `~/.openpalm/config/stack/core.compose.yml` | Core assistant runtime services; assistant also runs the scheduler co-process | -| `~/.openpalm/config/stack/services.compose.yml` | First-party optional services, profile-gated | -| `~/.openpalm/config/stack/portals.compose.yml` | First-party optional portals, profile-gated | +| `~/.openpalm/system/stack/core.compose.yml` | Core assistant runtime services; assistant also runs the scheduler co-process | +| `~/.openpalm/system/stack/services.compose.yml` | First-party optional services, profile-gated | +| `~/.openpalm/system/stack/portals.compose.yml` | First-party optional portals, profile-gated | | `~/.openpalm/config/stack/custom.compose.yml` | User custom services and overlays | | `~/.openpalm/knowledge/env/stack.env` | System-managed non-secret values: ports, UID/GID, image tags, paths, hardware profile selections | | `~/.openpalm/knowledge/secrets/` | System-managed secret files; directory mode `0700`, file mode `0600` | @@ -47,15 +47,14 @@ grep '^OP_ENABLED_ADDONS=' ~/.openpalm/knowledge/env/stack.env ## Building the Compose Command -Use the generated `run.sh` for the exact live stack command. It already -includes the correct compose files, non-secret env file, and profile selection. +Use the same managed/user compose file list for every command. First-party +addons are enabled with Compose profiles such as `addon.chat`. ### Helper: `op` shell function Typing the full command every time is tedious. Add this shell function to your -`~/.bashrc` or `~/.zshrc` for ad hoc compose operations with core plus custom -overlays. Use generated `run.sh` when you need the exact first-party addon list -and profiles selected by OpenPalm tooling: +`~/.bashrc` or `~/.zshrc` for ad hoc compose operations with the managed stack +files plus your custom overlay: ```bash op() { @@ -72,9 +71,9 @@ op() { docker compose \ --project-name "$PROJECT_NAME" \ --env-file "$OP_HOME/knowledge/env/stack.env" \ - -f "$OP_HOME/config/stack/core.compose.yml" \ - -f "$OP_HOME/config/stack/services.compose.yml" \ - -f "$OP_HOME/config/stack/portals.compose.yml" \ + -f "$OP_HOME/system/stack/core.compose.yml" \ + -f "$OP_HOME/system/stack/services.compose.yml" \ + -f "$OP_HOME/system/stack/portals.compose.yml" \ -f "$OP_HOME/config/stack/custom.compose.yml" \ "$@" } @@ -82,9 +81,10 @@ op() { Pass manual profile flags before the compose subcommand when needed, for example `op --profile addon.chat config`. -The generated `run.sh` remains the primary operator-facing entrypoint for -starting/restarting the stack. It records the fixed compose file list and the -profile selection derived from `OP_ENABLED_ADDONS` in one place. +When bypassing the CLI/admin tooling, pass the active addon profiles yourself. +`OP_ENABLED_ADDONS` is an OpenPalm state record; Docker Compose only sees it if +the command also passes matching `--profile addon.` flags or a +`COMPOSE_PROFILES` value. ### Manual command (without the helper) @@ -97,9 +97,9 @@ PROJECT_NAME="${OP_PROJECT_NAME:-openpalm}" docker compose \ --project-name "$PROJECT_NAME" \ --env-file "$OP_HOME/knowledge/env/stack.env" \ - -f "$OP_HOME/config/stack/core.compose.yml" \ - -f "$OP_HOME/config/stack/services.compose.yml" \ - -f "$OP_HOME/config/stack/portals.compose.yml" \ + -f "$OP_HOME/system/stack/core.compose.yml" \ + -f "$OP_HOME/system/stack/services.compose.yml" \ + -f "$OP_HOME/system/stack/portals.compose.yml" \ -f "$OP_HOME/config/stack/custom.compose.yml" \ --profile addon.chat \ @@ -119,9 +119,9 @@ misconfiguration early — before containers are affected. docker compose \ --project-name "$PROJECT_NAME" \ --env-file "$OP_HOME/knowledge/env/stack.env" \ - -f "$OP_HOME/config/stack/core.compose.yml" \ - -f "$OP_HOME/config/stack/services.compose.yml" \ - -f "$OP_HOME/config/stack/portals.compose.yml" \ + -f "$OP_HOME/system/stack/core.compose.yml" \ + -f "$OP_HOME/system/stack/services.compose.yml" \ + -f "$OP_HOME/system/stack/portals.compose.yml" \ -f "$OP_HOME/config/stack/custom.compose.yml" \ --profile addon.chat \ config --quiet @@ -130,9 +130,9 @@ docker compose \ docker compose \ --project-name "$PROJECT_NAME" \ --env-file "$OP_HOME/knowledge/env/stack.env" \ - -f "$OP_HOME/config/stack/core.compose.yml" \ - -f "$OP_HOME/config/stack/services.compose.yml" \ - -f "$OP_HOME/config/stack/portals.compose.yml" \ + -f "$OP_HOME/system/stack/core.compose.yml" \ + -f "$OP_HOME/system/stack/services.compose.yml" \ + -f "$OP_HOME/system/stack/portals.compose.yml" \ -f "$OP_HOME/config/stack/custom.compose.yml" \ --profile addon.chat \ config --services @@ -145,9 +145,9 @@ docker compose \ docker compose \ --project-name "$PROJECT_NAME" \ --env-file "$OP_HOME/knowledge/env/stack.env" \ - -f "$OP_HOME/config/stack/core.compose.yml" \ - -f "$OP_HOME/config/stack/services.compose.yml" \ - -f "$OP_HOME/config/stack/portals.compose.yml" \ + -f "$OP_HOME/system/stack/core.compose.yml" \ + -f "$OP_HOME/system/stack/services.compose.yml" \ + -f "$OP_HOME/system/stack/portals.compose.yml" \ -f "$OP_HOME/config/stack/custom.compose.yml" \ --profile "$OP_VOICE_PROFILE" \ --profile "$OP_OLLAMA_PROFILE" \ @@ -256,6 +256,200 @@ Containers from addons no longer in the file list are stopped and removed. --- +## Temporary Isolated Stack Verification + +Use this flow before a release or after stack/entrypoint changes. It exercises a +real Compose stack without touching `~/.openpalm`, production ports, or the +default `openpalm` project name. It does not require live LLM provider +credentials; chat/model calls may fail, but health, client artifact +installation, static client serving, runtime config, CORS/preflight, and common +operator mistakes are covered. + +Important host caveat: some Docker daemons cannot bind source files from +`/tmp` because the daemon runs in a private mount namespace. If Docker reports a +secret file under `/tmp` as missing, use `/var/tmp` or a repo-local temporary +directory instead. The stack is still temporary; the key safety requirement is a +unique `OP_HOME`, `OP_PROJECT_NAME`, and non-production ports. + +Artifact boundary: this tarball path verifies an unpublished `@openpalm/client` +because the assistant mounts `knowledge/` at `/stash`. The guardian and skeleton +entrypoints still resolve `@openpalm/guardian` and `@openpalm/skeleton` from npm +by version. If local guardian or skeleton source differs from the npm artifact at +the same semver, this stack follows the npm artifact, not your working tree. + +### 1. Choose an isolated home and build the unpublished client tarball + +For a feature branch where `@openpalm/client@` is not on npm yet, build +and pack it locally, then install that tarball through the same assistant +entrypoint path by using a `file:/stash/...` version spec. + +```bash +REPO="$PWD" +VERIFY_ROOT="${VERIFY_ROOT:-$REPO/.tmp-openpalm-verify}" +VERIFY_HOME="$VERIFY_ROOT/home" +VERIFY_PROJECT="openpalm-verify" +VERIFY_VERSION="$(node -p "require('./package.json').version")" + +rm -rf "$VERIFY_ROOT" +mkdir -p "$VERIFY_HOME" + +bun run client:build +bun pm pack --cwd packages/client --destination "$VERIFY_ROOT" --quiet +CLIENT_TARBALL="$(ls "$VERIFY_ROOT"/openpalm-client-*.tgz | tail -1)" +``` + +Expected: the tarball contains at least `package/build/index.html`, +`package/build/.openpalm-client-version`, and `package/bin/serve.mjs`. + +```bash +tar -tf "$CLIENT_TARBALL" | grep -E 'package/(build/index.html|build/.openpalm-client-version|bin/serve.mjs)' +``` + +### 2. Seed the isolated OP_HOME + +```bash +rsync -a \ + --exclude=/package.json \ + --exclude=/manifest.json \ + --exclude=/tools.json \ + --exclude=/README.md \ + "$REPO/packages/skeleton/" "$VERIFY_HOME/" + +OP_HOME="$VERIFY_HOME" bun -e "import { ensureHomeDirs } from './packages/lib/src/index.ts'; ensureHomeDirs();" +mkdir -p "$VERIFY_HOME/knowledge/env" "$VERIFY_HOME/knowledge/secrets" +cp "$CLIENT_TARBALL" "$VERIFY_HOME/knowledge/$(basename "$CLIENT_TARBALL")" +printf '{}\n' > "$VERIFY_HOME/knowledge/secrets/auth.json" +chmod 600 "$VERIFY_HOME/knowledge/secrets/auth.json" + +for name in portal_chat_secret portal_api_secret portal_discord_secret portal_slack_secret op_guardian_admin_token op_guardian_mcp_token op_api_key; do + openssl rand -hex 16 > "$VERIFY_HOME/knowledge/secrets/$name" + chmod 600 "$VERIFY_HOME/knowledge/secrets/$name" +done +``` + +### 3. Write an isolated `stack.env` + +```bash +cat > "$VERIFY_HOME/knowledge/env/stack.env" </dev/null + +# Static client: HEAD must return headers and no body +curl -sS -o /dev/null -D - -X HEAD http://127.0.0.1:3840/ + +# Static client: SPA fallback +curl -fsS http://127.0.0.1:3840/connections/new | grep '' + +# Static client: runtime config is no-store and points at the host-published assistant URL +curl -fsS -D - http://127.0.0.1:3840/runtime-config.json + +# Guardian direct listener health +curl -fsS http://127.0.0.1:9190/health + +# Allowed-origin browser preflight reaches the direct listener path. +# Expected: HTTP 204 with access-control-allow-origin: http://127.0.0.1:3840. +# A 401 without CORS headers usually means the runtime installed a stale +# published guardian package instead of the local source you expected. +curl -i -sS -X OPTIONS http://127.0.0.1:9190/oc/session \ + -H 'Origin: http://127.0.0.1:3840' \ + -H 'Access-Control-Request-Method: POST' \ + -H 'Access-Control-Request-Headers: authorization, content-type, x-openpalm-user' +``` + +### 6. Edge cases to deliberately check + +| Case | How to test | Expected result | +|---|---|---| +| Production collision | Run `docker compose ls` and inspect ports before start | No `openpalm-verify` command uses project `openpalm` or production ports | +| Wrong compose tree | Replace `system/stack/core.compose.yml` with `config/stack/core.compose.yml` in a dry command | Command fails because managed compose files live under `system/stack` | +| Missing secret file | Move one `portal_*_secret` aside, run `compose_verify config --quiet` or `up -d` | Compose fails before guardian starts; restore the file and rerun | +| `/tmp` bind-source trap | Put `VERIFY_HOME` under `/tmp` on a snap/private-tmp Docker host | Docker may report existing secret files as missing; move to `/var/tmp` or repo-local tmp | +| Unpublished client package | Set `OP_CLIENT_VERSION=$VERIFY_VERSION` before npm publish | Assistant logs npm 404 and skips client co-process; use `file:/stash/.tgz` | +| Published package drift | Set `OP_GUARDIAN_NPM_VERSION` or `OP_SKELETON_VERSION` to a semver that exists on npm while local source has newer same-version commits | Stack boots from the npm artifact; behavior may differ from local source, such as stale CORS/preflight handling | +| Port already allocated | Pre-bind `3840`/`4820`/`9190` or reuse an old project | Docker fails port programming; choose new ports and rerun `compose_verify up -d` | +| Direct ingress disabled | Set `GUARDIAN_DIRECT_INGRESS=false`, recreate guardian, retry browser preflight | `/oc/*` preflight returns `404 not_found`, not `204`; current guardian code also keeps allowed-origin CORS headers on the error | + +### 7. Teardown + +```bash +compose_verify down --volumes --remove-orphans + +# Container-created files may be root-owned. Use Docker to repair ownership +# before deleting the temporary tree. +docker run --rm -v "$VERIFY_ROOT:/cleanup" alpine sh -c "chown -R $(id -u):$(id -g) /cleanup && rm -rf /cleanup/* /cleanup/.[!.]* /cleanup/..?*" +rmdir "$VERIFY_ROOT" +``` + +Record all failures with: command, exit code, relevant `compose_verify ps -a`, +and the shortest useful `compose_verify logs --tail 100 ` excerpt. + +--- + ## Environment Variable Precedence Docker Compose resolves variables at two distinct stages, and mixing them up is @@ -387,4 +581,4 @@ directly — extract and start. | [troubleshooting.md](../troubleshooting.md) | Common problems and fixes | | [core-principles.md](../technical/core-principles.md) | Architectural rules and filesystem contract | | [environment-and-mounts.md](../technical/environment-and-mounts.md) | Per-service mount and env details | -| `.openpalm/config/stack/README.md` | Stack directory quick reference | +| `$OP_HOME/system/stack/` and `$OP_HOME/config/stack/custom.compose.yml` | Managed compose files plus user overlay | diff --git a/docs/operations/manual-headless-install.md b/docs/operations/manual-headless-install.md index cf60e25cc..3747fee6e 100644 --- a/docs/operations/manual-headless-install.md +++ b/docs/operations/manual-headless-install.md @@ -147,12 +147,26 @@ wizard drives. ```yaml version: 2 +llm: + provider: openai + model: gpt-4o + baseUrl: https://api.openai.com/v1 +embedding: + provider: openai + model: text-embedding-3-small + dims: 1536 + baseUrl: https://api.openai.com/v1 security: uiLoginPassword: change-me-please # min 8 characters -connections: [] +connections: + - id: openai + name: OpenAI + provider: openai + baseUrl: https://api.openai.com/v1 + apiKey: sk-... ``` -`connections` is required (may be empty). Everything else is optional: +Everything else is optional: ```yaml version: 2 @@ -193,6 +207,38 @@ openpalm install --file ./setup-spec.yaml # write config and start you want to assert the config was assembled correctly without needing a Docker daemon to bring services up. +### Persisting isolated runtime overrides + +When scripting an isolated install, set runtime overrides in the install shell. +The CLI now persists the following non-secret overrides into +`knowledge/env/stack.env` so a later `openpalm start` reuses the same isolated +project and port layout automatically: + +- `OP_PROJECT_NAME` +- `OP_ASSISTANT_PORT` +- `OP_HOST_UI_PORT` +- `OP_HOST_CLIENT_PORT` +- `OP_CLIENT_PORT` + +Example: + +```bash +OP_HOME="$PWD/.tmp-openpalm-install/home" \ +OP_PROJECT_NAME=openpalm-test-install \ +OP_ASSISTANT_PORT=4802 \ +OP_HOST_UI_PORT=9302 \ +OP_HOST_CLIENT_PORT=9392 \ +OP_CLIENT_PORT=3842 \ +openpalm install --file ./setup-spec.yaml --no-start + +# Later, the same install can be started without re-specifying those overrides: +OP_HOME="$PWD/.tmp-openpalm-install/home" openpalm start +``` + +Without those persisted overrides, a later `openpalm start` falls back to the +default project name and default ports, which can collide with a live local +stack. + ### Test coverage (the CI exercise) `packages/cli/src/main.test.ts` exercises `install --no-start --file ` diff --git a/docs/operations/release-management.md b/docs/operations/release-management.md index 3ee867282..1fd97c296 100644 --- a/docs/operations/release-management.md +++ b/docs/operations/release-management.md @@ -1,6 +1,6 @@ # Release Management -> **Last updated 2026-06-21** against the live codebase at 0.12.22. This supersedes the pre-0.12 `platform-release.yml` documentation. +> **Last updated 2026-07-07** against the live codebase at 0.12.52. This supersedes the pre-0.12 `platform-release.yml` documentation. --- @@ -8,6 +8,10 @@ All releases run through `.github/workflows/release.yml` (manual `workflow_dispatch` only). Version computation and file stamping are handled by `scripts/bump-unit.mjs`. npm publishing flows through the reusable `.github/workflows/publish-npm-package.yml` (OIDC provenance, single trusted publisher). +For an operator-grade, repeatable RC procedure with merge gates, exact commands, +evidence capture, and post-publish verification, use the +[RC release runbook](release-rc-runbook.md). + **TAG-LAST:** The git tag and GitHub release are created as the very last step. "Tag exists = fully published." This makes releases safe to retry — re-running a failed release replays only the failed jobs. **Always dry-run first:** `dry_run=true` (the default) validates the entire plan, builds every artifact, and runs the test gate without publishing, committing, or tagging. @@ -18,7 +22,7 @@ All releases run through `.github/workflows/release.yml` (manual `workflow_dispa | Unit | What it publishes | Version anchor | |---|---|---| -| `platform` | @openpalm/lib, openpalm (CLI), @openpalm/ui, @openpalm/skeleton, @openpalm/guardian (all npm) + CLI binaries + optional Electron | root `package.json` | +| `platform` | @openpalm/lib, openpalm (CLI), @openpalm/ui, @openpalm/client, @openpalm/skeleton, @openpalm/guardian (all npm) + CLI binaries + optional Electron | root `package.json` | | `portals` | `openpalm/portal` Docker image | `portals/discord/package.json` | | `assistant` | `openpalm/assistant` Docker image | `containers/assistant/VERSION` | | `guardian` | @openpalm/guardian + @openpalm/skeleton (npm) + optional Docker image | `packages/guardian/package.json` | @@ -50,7 +54,7 @@ gh workflow run release.yml \ -f dry_run=true ``` -Review the workflow output. Verify the computed version, the files that will be stamped, and the npm regression guard output. +Review the workflow output. Verify the computed version, the files that will be stamped, and the npm regression guard output. For `platform` and `all`, confirm the stamp includes `packages/cli/package.json` and rewrites its exact `@openpalm/skeleton` pin to the release version. ### Step 2 — real release @@ -149,7 +153,7 @@ All npm publishes flow through `publish-npm-package.yml` as the single OIDC trus - Workflow: `release.yml` - Environment: (none) -Set this for: `@openpalm/lib`, `openpalm`, `@openpalm/ui`, `@openpalm/skeleton`, `@openpalm/guardian`. +Set this for: `@openpalm/lib`, `openpalm`, `@openpalm/ui`, `@openpalm/client`, `@openpalm/skeleton`, `@openpalm/guardian`. --- @@ -159,10 +163,40 @@ When promoting a `0.X.Y-rc.N` or `0.X.Y-beta.N` line to stable `0.X.Y`: - [ ] Cut the stable platform release (no `-` suffix) — publishes npm under `latest`, creates Docker `latest` tags - [ ] Verify `@openpalm/ui@latest` resolves to the current UI (it ships with `platform`) +- [ ] Verify `@openpalm/client@latest` resolves to the current client (it ships with `platform`) - [ ] Verify guardian and skeleton `latest` dist-tags are current - [ ] Confirm Docker `latest` tags exist for all images (first ever `latest` for a new major line) - [ ] Update `CHANGELOG.md` +## Runtime artifact env pins + +These non-secret `stack.env` vars control the exact npm artifacts the running platform installs or serves: + +| Variable | Used by | Resolution | Notes | +|---|---|---|---| +| `OP_UI_VERSION` | Host UI updater / seeding path | `OP_UI_VERSION` -> channel/default logic in the host control plane | Host-side UI artifact only | +| `OP_CLIENT_VERSION` | Assistant container entrypoint | `OP_CLIENT_VERSION` -> image `PLATFORM_VERSION` -> hard error | Exact-pins `@openpalm/client`; no `latest` fallback | +| `OP_SKELETON_VERSION` | Assistant + guardian entrypoints | Assistant: `OP_SKELETON_VERSION` -> image `PLATFORM_VERSION` -> hard error. Guardian: `OP_SKELETON_VERSION` -> guardian package version. | Exact-pins `@openpalm/skeleton`; keep equal to the platform version in normal releases | + +`OP_CLIENT_PORT` and `OP_CLIENT_BIND_ADDRESS` are separate: they control the assistant container's published client co-process listener (`127.0.0.1:3810` by default), not the host-local PWA/dev origin. + +## Release Smoke Checklist + +For a full coordinated release candidate, use the dedicated +[`unit=all` RC checklist](unit-all-rc-checklist.md). It expands this smoke list +into a pre-publish and post-publish worksheet covering packaging, deployment, +permissions, upgrade, rootless ownership, browser-backed flows, and shipped +artifact verification. + +For the ordered execution procedure that drives that checklist, use the +[RC release runbook](release-rc-runbook.md). + +- [ ] `electron-host`: launch Electron against a seeded install; verify the window lands on the client chat at `http://127.0.0.1:3890/chat`, and host routes remain available. +- [ ] `host-ui`: run `openpalm admin`; verify the browser opens on the loopback host UI and `/host`, `/connections`, and `/chat` all load. +- [ ] `assistant-container`: boot the assistant with `OP_CLIENT_VERSION` and `OP_SKELETON_VERSION` overrides; verify the container installs those exact versions, serves the static client on the assistant's published client port, and chat reaches the locked default assistant connection. +- [ ] `localhost PWA install`: from the harness-served client origin `http://127.0.0.1:3890`, verify installability and that the installed app reopens on the same origin. +- [ ] `hosted PWA install`: from the hosted client origin (currently `https://app.openpalm.dev` in tests/docs), verify installability, `/api/runtime` compatibility, and that remote connections require HTTPS guardians plus the expected guardian CORS allowlist. + --- ## Skeleton seeding diff --git a/docs/operations/release-rc-runbook.md b/docs/operations/release-rc-runbook.md new file mode 100644 index 000000000..78091b6ed --- /dev/null +++ b/docs/operations/release-rc-runbook.md @@ -0,0 +1,422 @@ +# RC Release Runbook + +Repeatable operator runbook for cutting an OpenPalm release candidate with the +single orchestrator at `.github/workflows/release.yml`. + +Use this alongside: + +- [Release Management](release-management.md) +- [Unit=All RC Checklist](unit-all-rc-checklist.md) + +This runbook focuses on **how** to execute the checklist in a repeatable way: +ordered steps, exact commands, evidence to capture, and go/no-go gates. + +--- + +## Audience + +This is for the maintainer or operator preparing a coordinated release such as +`0.13.0-rc.1`. + +--- + +## Scope + +This runbook assumes a coordinated `unit=all` RC release and covers: + +- merge readiness +- npm trusted-publisher readiness +- workflow dry run +- local/runtime validation +- post-publish verification + +It does not replace the checklist; it tells you how to drive it. + +--- + +## Release Variables + +Set these once in your shell before running the procedure. + +```bash +export RELEASE_REF="main" +export RC_VERSION="0.13.0-rc.1" +export RC_UNIT="all" +export REPO="$PWD" +export RC_EVIDENCE_DIR="$REPO/.release-evidence/$RC_VERSION" +mkdir -p "$RC_EVIDENCE_DIR" +``` + +Recommended convention: + +- `RELEASE_REF` should be the branch that actually contains the release workflow + changes you intend to use +- for the final RC cut, that should normally be `main` after merge + +--- + +## Go / No-Go Gates + +Do not merge and do not cut `0.13.0-rc.1` until every gate here is green. + +### Merge gates + +- [ ] release workflow changes required for the RC are merged +- [ ] `git status --short` is clean on the merge target +- [ ] `@openpalm/client` first-publish readiness is confirmed +- [ ] all local blocker fixes are committed and pushed + +### RC cut gates + +- [ ] branch-correct `release.yml` dry run passes for `unit=all` +- [ ] checklist items marked required in `unit-all-rc-checklist.md` pass +- [ ] no unresolved security-boundary regressions remain +- [ ] no unresolved rootless/ownership regressions remain +- [ ] no unresolved guardian/client/skeleton artifact drift remains + +--- + +## Step 1: Confirm Registry And Publisher Readiness + +### 1.1 Check which packages already exist + +```bash +npm view @openpalm/client versions --json +npm view @openpalm/guardian versions --json +npm view @openpalm/skeleton versions --json +npm view @openpalm/ui versions --json +npm view openpalm versions --json +``` + +Expected at time of writing: + +- `@openpalm/client` may 404 if it has never been published +- `@openpalm/guardian`, `@openpalm/skeleton`, `@openpalm/ui`, and `openpalm` + should already exist + +Record: + +- command output +- whether `@openpalm/client` still needs first publish + +### 1.2 Confirm npm trusted publisher configuration + +Required packages: + +- `@openpalm/lib` +- `openpalm` +- `@openpalm/ui` +- `@openpalm/client` +- `@openpalm/skeleton` +- `@openpalm/guardian` + +Required trusted publisher values: + +- Repository: `itlackey/openpalm` +- Workflow: `release.yml` +- Environment: none + +If `@openpalm/client` is new, this is the one package most likely to still need +explicit npm-side setup. + +Pass criteria: + +- [ ] every required package has a matching trusted publisher entry +- [ ] the maintainer account has publish rights to `@openpalm/client` + +--- + +## Step 2: Confirm Merge Target Is Ready + +Run this on the branch you intend to merge. + +```bash +git status --short +git log --oneline -10 +``` + +Pass criteria: + +- [ ] no uncommitted changes +- [ ] intended release fixes are present + +If the release workflow or release-critical Docker/runtime fixes are not yet on +the merge target, stop here and merge first. + +--- + +## Step 3: Merge The Release Fixes + +Example sequence from a feature branch: + +```bash +git checkout main +git pull origin main +git merge --ff-only +git push origin main +``` + +If fast-forward is not possible, use your normal reviewed merge process. + +Pass criteria: + +- [ ] merge target now contains the release workflow fixes +- [ ] remote `main` is updated + +Important: + +- if you dispatch `gh workflow run release.yml` without `--ref`, GitHub uses the + workflow definition on the default branch +- for final RC validation, this is desirable only after merge + +--- + +## Step 4: Run The Coordinated Dry Run + +Dispatch the dry run from the merge target: + +```bash +gh workflow run release.yml \ + --ref "$RELEASE_REF" \ + -f unit="$RC_UNIT" \ + -f version="$RC_VERSION" \ + -f dry_run=true +``` + +Then inspect the latest run: + +```bash +gh run list --workflow release.yml --branch "$RELEASE_REF" --limit 5 +gh run view --json status,conclusion,url,jobs +``` + +What to look for: + +- `Compute version (all)` success +- `Preflight (test gate)` success +- `Stamp ` success +- `Docker openpalm/portal` success +- `Docker openpalm/assistant` success +- `Docker openpalm/guardian` success +- npm dry-run jobs success +- CLI binary jobs success +- Electron jobs success + +Pass criteria: + +- [ ] entire dry run succeeds +- [ ] no guardian Docker dry-run failure +- [ ] no regression guard failure + +If a single job fails: + +```bash +gh run view --job --log-failed +``` + +Capture the failing step and exact command/output. + +--- + +## Step 5: Run Local Preflight Parity + +```bash +bun install --frozen-lockfile +bun run client:build +bun run test +bun run ui:check +bun run ui-kit:check +bun run client:check +bun run --cwd packages/ui test:browsers +bun run ui:test:unit +bun run electron:test +bun run guardian:test +bun run cli:test +``` + +Guidance: + +- if `packages/ui test:browsers` fails only because Playwright tries to install + system packages with `sudo`, record it as an environment blocker rather than a + product regression +- any real test failure in product code is a no-go + +Pass criteria: + +- [ ] all product tests/checks pass +- [ ] any env-only limitation is understood and documented + +--- + +## Step 6: Run Runtime Validation + +Use the dedicated checklist for exact items. + +Minimum required runtime validations before RC publish: + +- [ ] isolated assistant/client runtime +- [ ] guardian direct-ingress/CORS/auth +- [ ] browser-backed stack test +- [ ] rootless ownership stack smoke +- [ ] rootless portal-discord smoke +- [ ] rootless host-swap smoke +- [ ] fresh install smoke +- [ ] upgrade smoke + +Reference commands and detailed pass criteria live in: + +- [Unit=All RC Checklist](unit-all-rc-checklist.md) +- [Manual Compose Runbook](manual-compose-runbook.md) + +Guidance: + +- use unique ports/project names for every isolated stack run +- collect guardian logs whenever a direct-ingress or auth path fails +- when validating guardian behavior, prefer the actual built image/runtime over + source-only reasoning + +--- + +## Step 7: Make The Merge Decision + +### Merge if all of these are true + +- [ ] workflow dry run is green on the merge target +- [ ] no unresolved release blockers remain +- [ ] any waivers are explicit and low risk + +### Do not merge if any of these are true + +- [ ] guardian Docker dry run still fails +- [ ] `@openpalm/client` publish readiness is unknown +- [ ] rootless ownership is still red +- [ ] guardian direct-ingress behavior differs between source and built artifact +- [ ] upgrade/rollback behavior has not been validated + +--- + +## Step 8: Cut `0.13.0-rc.1` + +Only after merge and dry-run success: + +```bash +gh workflow run release.yml \ + --ref "$RELEASE_REF" \ + -f unit="$RC_UNIT" \ + -f version="$RC_VERSION" \ + -f dry_run=false +``` + +Then monitor: + +```bash +gh run list --workflow release.yml --branch "$RELEASE_REF" --limit 5 +gh run view --json status,conclusion,url,jobs +``` + +If the live release fails partway through: + +```bash +gh run rerun --failed +``` + +Do not start a fresh release for the same version unless you have a specific, +reviewed reason. + +--- + +## Step 9: Post-Publish Verification + +Immediately verify the real artifacts. + +### npm + +```bash +npm view @openpalm/lib@"$RC_VERSION" version +npm view openpalm@"$RC_VERSION" version +npm view @openpalm/ui@"$RC_VERSION" version +npm view @openpalm/client@"$RC_VERSION" version +npm view @openpalm/guardian@"$RC_VERSION" version +npm view @openpalm/skeleton@"$RC_VERSION" version +npm view @openpalm/discord-portal@"$RC_VERSION" version +npm view @openpalm/slack-portal@"$RC_VERSION" version +``` + +### dist-tags + +```bash +npm view @openpalm/client dist-tags --json +npm view @openpalm/guardian dist-tags --json +npm view @openpalm/skeleton dist-tags --json +``` + +### Docker + +```bash +docker buildx imagetools inspect openpalm/assistant:"$RC_VERSION" +docker buildx imagetools inspect openpalm/guardian:"$RC_VERSION" +docker buildx imagetools inspect openpalm/portal:"$RC_VERSION" +``` + +### GitHub release and tags + +Verify: + +- `platform-$RC_VERSION` +- `portals-$RC_VERSION` +- `assistant-$RC_VERSION` +- `guardian-$RC_VERSION` +- `electron-$RC_VERSION` +- `$RC_VERSION` + +Pass criteria: + +- [ ] all expected npm versions resolve +- [ ] all expected Docker tags resolve +- [ ] all expected tags/releases exist + +--- + +## Step 10: Final RC Signoff + +Create a release note or signoff comment that includes: + +- RC version +- release run URL +- checklist status +- any waivers +- known issues still accepted into RC + +Recommended signoff template: + +```text +OpenPalm 0.13.0-rc.1 signoff + +- Merge target: main +- Release workflow run: +- Checklist: pass / fail / waived items listed below +- npm verification: pass +- Docker verification: pass +- GitHub tags/release verification: pass +- Known accepted RC limitations: +``` + +--- + +## Maintainer Notes + +- Prefer `--ref "$RELEASE_REF"` for all release workflow dispatches so the + intended workflow definition is used +- Treat a new package like `@openpalm/client` as an npm-configuration task as + well as a code/release task +- Keep the checklist and this runbook updated together; the checklist is the + gate list, this runbook is the operating procedure + +--- + +## Related Docs + +- [Release Management](release-management.md) +- [Unit=All RC Checklist](unit-all-rc-checklist.md) +- [Manual Compose Runbook](manual-compose-runbook.md) +- [Core Principles](../technical/core-principles.md) diff --git a/docs/operations/unit-all-rc-checklist.md b/docs/operations/unit-all-rc-checklist.md new file mode 100644 index 000000000..1735f4a2d --- /dev/null +++ b/docs/operations/unit-all-rc-checklist.md @@ -0,0 +1,566 @@ +# Unit=All RC Checklist + +Operator worksheet for a coordinated `unit=all` release candidate. Use this when +the release contains foundational churn across packaging, deployment, +permissions, runtime artifact resolution, and install/upgrade behavior. + +This checklist is intentionally stricter than the workflow preflight. The goal +is to prove that the release works as shipped across the major runtime surfaces, +not just that the source tree is green. + +--- + +## Scope + +`unit=all` publishes all coordinated release artifacts at one version: + +- npm: `@openpalm/{lib,ui,client,guardian,skeleton}`, `openpalm`, `@openpalm/{discord,slack}-portal` +- Docker: `openpalm/{assistant,guardian,portal}` +- Electron installers +- CLI binaries +- Git tags and GitHub release metadata + +It does **not** build the voice image. + +--- + +## Evidence + +For every checklist item, capture: + +- command run +- exit code +- key output or log excerpt +- screenshot for browser/Electron/installability checks +- pass/fail note and any follow-up issue + +If a step is skipped, record the reason explicitly. + +--- + +## Setup + +Set shared variables once: + +```bash +export RC_VERSION="0.12.53-rc.1" +export REPO="$PWD" +export VERIFY_ROOT="$REPO/.tmp-openpalm-rc" +export VERIFY_HOME="$VERIFY_ROOT/home" +export VERIFY_PROJECT="openpalm-rc" +``` + +Important artifact note: + +- pre-publish checks validate source, local builds, and release orchestration +- post-publish checks validate the actual published npm packages, container images, installers, and tags +- if there is no private staging registry, the public RC is the first true shipped-artifact test for guardian, skeleton, client, UI, CLI, and images + +--- + +## Exit Criteria + +Do not publish or announce the RC unless every required item below is marked +pass or has an explicit, reviewed waiver. + +- [ ] `unit=all` workflow dry run passed for the exact RC version +- [ ] local preflight parity passed +- [ ] fresh install passed +- [ ] upgrade test passed +- [ ] failure/rollback test passed +- [ ] assistant/client isolated runtime test passed +- [ ] guardian direct-ingress/auth/CORS test passed +- [ ] browser-backed live stack test passed +- [ ] host UI smoke passed +- [ ] PWA smoke passed +- [ ] at least one real credentialed ingress path passed +- [ ] security-boundary verification passed +- [ ] rootless/ownership verification passed +- [ ] CLI binary smoke passed +- [ ] Electron installer smoke passed +- [ ] post-publish npm verification passed +- [ ] post-publish Docker verification passed +- [ ] post-publish GitHub tag/release verification passed +- [ ] post-publish shipped-artifact boot test passed + +--- + +## 1. Release Orchestrator Dry Run + +- [ ] Run the real release workflow for the exact RC version. + +```bash +gh workflow run release.yml -f unit=all -f version="$RC_VERSION" -f dry_run=true +``` + +Pass criteria: + +- [ ] the run starts successfully +- [ ] the computed version matches `RC_VERSION` +- [ ] no npm regression guard failure appears +- [ ] expected `unit=all` jobs are present: platform npm, portal npm, assistant image, guardian image, portal image, Electron, CLI binaries, tags/release staging +- [ ] no unexpected job skip appears + +Evidence: + +- workflow URL +- screenshots or copied logs for compute-version, regression guard, preflight, and dry-run preview + +--- + +## 2. Local Preflight Parity + +- [ ] Run the local equivalent of the release preflight. + +```bash +bun install --frozen-lockfile +bun run client:build +bun run test +bun run ui:check +bun run ui-kit:check +bun run client:check +bun run --cwd packages/ui test:browsers +bun run ui:test:unit +bun run electron:test +bun run guardian:test +bun run cli:test +``` + +Pass criteria: + +- [ ] every command exits 0 +- [ ] any warning is understood and non-blocking +- [ ] no test was skipped unexpectedly + +--- + +## 3. Fresh Install From Empty OP_HOME + +- [ ] Test the supported install path against a brand-new `OP_HOME`. +- [ ] Use isolated ports and project name. + +Pass criteria: + +- [ ] install seeds `system/stack/{core,services,portals}.compose.yml` +- [ ] install seeds `config/stack/custom.compose.yml` +- [ ] install creates expected `knowledge/`, `data/`, and `workspace/` trees +- [ ] assistant boots successfully +- [ ] host UI is reachable +- [ ] no manual repair is needed + +Evidence: + +- resulting directory tree summary +- health output +- screenshot of first successful UI load + +--- + +## 4. Upgrade Existing OP_HOME + +- [ ] Test against a realistic pre-existing install. +- [ ] Prefer a real preserved fixture over a synthetic minimal tree. + +Pass criteria: + +- [ ] existing user-owned files in `config/` survive untouched unless explicitly intended +- [ ] managed `system/` files update correctly +- [ ] addon activation state still resolves correctly +- [ ] `knowledge/secrets/auth.json`, tasks, AKM data, and workspace remain intact +- [ ] upgraded stack boots cleanly + +Evidence: + +- before/after file diff summary for `config/`, `system/`, and `state/` +- health output after upgrade + +--- + +## 5. Failure And Rollback Test + +- [ ] Induce a controlled deployment failure. +- [ ] Good options: missing required secret, bad image tag, invalid compose substitution. + +Pass criteria: + +- [ ] apply fails closed +- [ ] previous known-good configuration remains recoverable +- [ ] rollback snapshot behavior matches the documented contract +- [ ] no user-owned config is clobbered during the failed attempt + +Evidence: + +- failing command +- failure output +- rollback snapshot path +- post-failure stack status + +--- + +## 6. Assistant And Client Isolated Runtime Test + +- [ ] Run the isolated compose flow from `manual-compose-runbook.md`. +- [ ] Build and pack a local client tarball. +- [ ] Boot assistant with `OP_CLIENT_VERSION=file:/stash/...`. + +Verify: + +```bash +curl -fsS http://127.0.0.1:4820/health +curl -sS -o /dev/null -D - -X HEAD http://127.0.0.1:3840/ +curl -fsS http://127.0.0.1:3840/connections/new | grep '' +curl -fsS -D - http://127.0.0.1:3840/runtime-config.json +``` + +Pass criteria: + +- [ ] assistant health returns 200 +- [ ] client `HEAD /` succeeds +- [ ] SPA fallback serves the app shell +- [ ] `runtime-config.json` is `cache-control: no-store` +- [ ] the locked default connection points at the host-published assistant URL + +--- + +## 7. Guardian Direct Ingress, Auth, And CORS + +- [ ] Boot guardian in the isolated stack with direct ingress enabled. + +Verify: + +```bash +curl -fsS http://127.0.0.1:9190/health +curl -i -sS -X OPTIONS http://127.0.0.1:9190/oc/session \ + -H 'Origin: http://127.0.0.1:3840' \ + -H 'Access-Control-Request-Method: POST' \ + -H 'Access-Control-Request-Headers: authorization, content-type, x-openpalm-user' +``` + +- [ ] Re-run with `GUARDIAN_DIRECT_INGRESS=false`. + +Pass criteria: + +- [ ] guardian health returns 200 +- [ ] allowed-origin preflight returns `204` with expected CORS headers +- [ ] disabled direct ingress returns `404 not_found` +- [ ] unauthenticated direct `/oc/*` traffic is rejected explicitly +- [ ] behavior matches current source expectations, not stale published package behavior + +Evidence: + +- guardian logs +- preflight response headers + +--- + +## 8. Browser-Backed Live Stack Test + +- [ ] Run stack-backed UI tests. + +```bash +bun run ui:test:stack +``` + +- [ ] If the RC is meant to represent the full browser experience, also run: + +```bash +bun run ui:test:e2e +``` + +Pass criteria: + +- [ ] login succeeds +- [ ] connections UI works +- [ ] chat UI works +- [ ] runtime-config-driven routing works +- [ ] no assistant/client/UI artifact mismatch appears + +--- + +## 9. Host UI Smoke + +- [ ] Launch the host-served UI the way users actually do. + +Pass criteria: + +- [ ] `/host` loads +- [ ] `/connections` loads +- [ ] `/chat` loads +- [ ] admin login succeeds +- [ ] provider/auth state looks correct +- [ ] host UI and assistant-container client do not conflict or drift + +Evidence: + +- screenshots of `/host`, `/connections`, `/chat` + +--- + +## 10. PWA Smoke + +- [ ] Test localhost PWA installability from the host-served client origin. +- [ ] Test hosted PWA installability if applicable to this RC. + +Pass criteria: + +- [ ] installability is present on localhost +- [ ] installed localhost app reopens on the same origin and still works +- [ ] hosted origin works with expected runtime config behavior +- [ ] remote connection behavior matches HTTPS and guardian CORS requirements + +Evidence: + +- screenshots of install prompt or installed app + +--- + +## 11. Real Credentialed Ingress Path + +- [ ] Test at least one real credentialed ingress path. +- [ ] Minimum acceptable: guardian-hosted `chat` or `api`. +- [ ] Better: one baked portal adapter as well. + +Pass criteria: + +- [ ] authenticated request reaches guardian +- [ ] session is created successfully +- [ ] message flow completes successfully +- [ ] audit logging is present +- [ ] unauthenticated request is rejected correctly + +--- + +## 12. Security Boundary Verification + +- [ ] Inspect effective compose config and running containers. + +Suggested checks: + +```bash +docker inspect +docker inspect +docker compose ... config +``` + +Pass criteria: + +- [ ] assistant has no Docker socket mount +- [ ] assistant has no default path to the host admin process +- [ ] guardian remains the only intended ingress bridge +- [ ] admin remains loopback-only by default +- [ ] no raw secret env values appear in inspect output +- [ ] secrets are granted as files, not broad env files + +--- + +## 13. Rootless, Ownership, And Host Accessibility + +- [ ] Run the guardrails already used in CI. + +```bash +./scripts/validate-rootless-guardrails.sh +source scripts/rootless-smoke-fixture.sh && smoke_build_images assistant guardian portal +OP_ROOTLESS_SMOKE_SKIP_BUILD=1 ./scripts/rootless-ownership-smoke.sh stack +OP_ROOTLESS_SMOKE_SKIP_BUILD=1 ./scripts/rootless-ownership-smoke.sh portal-discord +OP_ROOTLESS_SMOKE_SKIP_BUILD=1 ./scripts/rootless-host-swap-smoke.sh +``` + +Pass criteria: + +- [ ] no root-owned files appear under bind-mounted `OP_HOME` +- [ ] host user can still read and write expected files after boot +- [ ] secrets keep strict file modes +- [ ] no ownership-repair surprise appears during host swap + +--- + +## 14. Cross-Environment Manual Checks + +- [ ] Validate on native Linux. +- [ ] Validate on at least one Docker Desktop-style environment if that platform is supported. + +Pass criteria: + +- [ ] install/start/upgrade works without ownership or mount surprises +- [ ] any unsupported environment is explicitly waived with noted risk + +Evidence: + +- platform tested +- filesystem/runtime notes +- any deviations or waivers + +--- + +## 15. Addon And Overlay Scenario Matrix + +- [ ] Test no addons enabled. +- [ ] Test `chat` only. +- [ ] Test `api` only. +- [ ] Test a representative `custom.compose.yml` overlay. +- [ ] Test addon enable and disable round trip. + +Pass criteria: + +- [ ] compose profile resolution matches enabled addon state +- [ ] expected services appear and unexpected services do not +- [ ] no stale service lingers after disable +- [ ] user overlay still composes cleanly with the managed file set + +--- + +## 16. CLI Binary Smoke + +- [ ] Test at least one produced CLI binary artifact. + +Verify: + +```bash +openpalm --version +openpalm ui serve --help +openpalm install --help +``` + +Pass criteria: + +- [ ] reported version matches `RC_VERSION` +- [ ] binary starts and prints expected help/version text +- [ ] install/start flow works from the built artifact, not just repo source + +--- + +## 17. Electron Installer Smoke + +- [ ] Install and launch the Electron build that `unit=all` will ship. + +Pass criteria: + +- [ ] app launches successfully +- [ ] packaged skeleton resolution works +- [ ] expected client/host UI routing works +- [ ] no packaged/runtime asset mismatch appears +- [ ] no repo checkout is required for normal operation + +Evidence: + +- installer artifact name +- screenshots of first successful launch + +--- + +## 18. Auth, Permissions, And Policy Paths + +- [ ] Verify admin auth success and failure. +- [ ] Verify guardian auth success and failure. +- [ ] Verify denied-origin and allowed-origin direct-ingress behavior. +- [ ] Verify content-validation fail-closed behavior if this release touched it materially. + +Pass criteria: + +- [ ] every failure path is explicit and safe +- [ ] no bypassable permission/auth path is found +- [ ] no unexpected leniency appears on direct ingress or browser-facing surfaces + +--- + +## 19. Post-Publish npm Verification + +- [ ] Verify each published npm artifact from the registry. + +```bash +npm view @openpalm/lib@"$RC_VERSION" version +npm view openpalm@"$RC_VERSION" version +npm view @openpalm/ui@"$RC_VERSION" version +npm view @openpalm/client@"$RC_VERSION" version +npm view @openpalm/guardian@"$RC_VERSION" version +npm view @openpalm/skeleton@"$RC_VERSION" version +npm view @openpalm/discord-portal@"$RC_VERSION" version +npm view @openpalm/slack-portal@"$RC_VERSION" version +``` + +- [ ] Record dist-tags for key artifacts. + +```bash +npm view @openpalm/client dist-tags --json +npm view @openpalm/guardian dist-tags --json +npm view @openpalm/skeleton dist-tags --json +``` + +Pass criteria: + +- [ ] every expected package exists at the exact RC version +- [ ] dist-tags match prerelease expectations + +--- + +## 20. Post-Publish Docker Verification + +- [ ] Verify each image tag from the registry. + +```bash +docker buildx imagetools inspect openpalm/assistant:"$RC_VERSION" +docker buildx imagetools inspect openpalm/guardian:"$RC_VERSION" +docker buildx imagetools inspect openpalm/portal:"$RC_VERSION" +``` + +Pass criteria: + +- [ ] each tag resolves successfully +- [ ] manifest metadata looks correct +- [ ] no `latest` tag was created for the prerelease + +--- + +## 21. Post-Publish GitHub Tag And Release Verification + +- [ ] Verify expected tags exist. +- [ ] For `unit=all`, verify: + - `platform-$RC_VERSION` + - `portals-$RC_VERSION` + - `assistant-$RC_VERSION` + - `guardian-$RC_VERSION` + - `electron-$RC_VERSION` + - `$RC_VERSION` +- [ ] Verify the GitHub release exists and expected assets are attached. + +Pass criteria: + +- [ ] every expected tag exists +- [ ] tags point at the intended commit +- [ ] release assets are present and downloadable + +--- + +## 22. Post-Publish Shipped-Artifact Boot Test + +- [ ] Re-run the isolated stack against the actual published RC artifacts. +- [ ] Use the published npm packages and published images, not local source. + +Pass criteria: + +- [ ] runtime behavior matches pre-publish expectations +- [ ] no guardian/skeleton/client artifact drift appears +- [ ] health, client serving, auth, and direct-ingress checks still pass + +--- + +## Risk Focus + +If time is limited, prioritize these in order: + +1. shipped guardian/skeleton/client artifact behavior +2. upgrade plus rollback behavior +3. rootless ownership and host accessibility +4. browser-backed live stack flow +5. real ingress auth/CORS/permission behavior +6. Electron and CLI packaged artifact sanity + +--- + +## Related Documents + +- [Release Management](release-management.md) +- [Manual Compose Runbook](manual-compose-runbook.md) +- [Core Principles](../technical/core-principles.md) +- [Environment Variables, Mounts, and Network Wiring](../technical/environment-and-mounts.md) +- [Release Architecture](../technical/release-architecture.md) diff --git a/docs/technical/api-spec.md b/docs/technical/api-spec.md index 6009fcd29..e6f749357 100644 --- a/docs/technical/api-spec.md +++ b/docs/technical/api-spec.md @@ -6,8 +6,15 @@ This document describes the Admin API routes currently implemented in ## Conventions - Base URL: `http://localhost:3880` +- Namespaces (Phase 4 of `ui-runtime-modes-plan.md`): privileged host + endpoints live under `/api/host/*` (requireAdmin + a server-side + `requireCapability('host:…')` guard — 403 `capability_not_available` in + modes without host capabilities), assistant-owned settings under + `/api/assistant/*` (`assistant-settings:*` capabilities), session lifecycle + under `/api/auth/*`, connections under `/api/connections/*`. The legacy + `/admin/*` namespace is a router 404 since Phase 4 (no alias). - Protected endpoints require the `op_session` cookie (HttpOnly, SameSite=Strict). - The browser obtains the cookie via `POST /admin/auth/login` (password in body). + The browser obtains the cookie via `POST /api/auth/login` (password in body). The legacy `x-admin-token` / `Authorization: Bearer` header fallbacks were removed in Phase 2 of `docs/technical/auth-and-proxy-refactor-plan.md`. `OP_UI_LOGIN_PASSWORD` is supplied to the admin process from @@ -97,14 +104,14 @@ Response: Policy for this section: - `config/` is the user-owned persistent source of truth. -- `POST /admin/install`, `POST /admin/update`, and startup auto-apply are +- `POST /api/host/install`, `POST /api/host/update`, and startup auto-apply are automatic lifecycle operations: non-destructive for existing user config files in `config/`; they only seed missing defaults. -- Explicit mutation endpoints (`POST /admin/addons`, `POST /admin/addons/:name`, - `POST /api/setup/complete`, `PATCH /admin/akm`) are the allowed write path +- Explicit mutation endpoints (`POST /api/host/addons`, `POST /api/host/addons/:name`, + `POST /api/setup/complete`, `PATCH /api/assistant/akm`) are the allowed write path for requested config changes. -### `POST /admin/install` +### `POST /api/host/install` - Ensures directories + OpenCode starter config + starter user secrets. - Seeds only missing defaults in `config/`; never overwrites existing user files. @@ -122,7 +129,7 @@ Response: } ``` -### `POST /admin/update` +### `POST /api/host/update` - Non-destructive for existing user config; seeds missing defaults only. - Writes configuration files to their final locations. @@ -134,7 +141,7 @@ Response: { "ok": true, "restarted": ["guardian"], "dockerAvailable": true } ``` -### `POST /admin/uninstall` +### `POST /api/host/uninstall` - Runs compose down. - Does not delete or rewrite existing user config in `config/`. @@ -146,14 +153,14 @@ Response: { "ok": true, "stopped": ["assistant"], "dockerAvailable": true } ``` -> The former `POST /admin/upgrade` endpoint was removed in 0.12.36. Updates now -> run through `POST /admin/update` (above) plus `POST /admin/migrate-apply`, +> The former `POST /api/host/upgrade` endpoint was removed in 0.12.36. Updates now +> run through `POST /api/host/update` (above) plus `POST /api/host/migrate-apply`, > which performs the full reconcile under lock when a home is detected stale (see > the splash apply flow). ## Container Operations -### `GET /admin/containers/list` +### `GET /api/host/containers/list` Returns in-memory service state synced with live Docker container data when Docker is available. @@ -168,7 +175,7 @@ Response: } ``` -### `POST /admin/containers/pull` +### `POST /api/host/containers/pull` - Pulls the latest images for all services in the current compose file list. - After a successful pull, recreates containers with the updated images via `compose up`. @@ -187,9 +194,9 @@ Error responses: - `502 pull_failed` — `docker compose pull` failed. - `502 up_failed` — Images pulled but container recreation failed. -### `POST /admin/containers/up` -### `POST /admin/containers/down` -### `POST /admin/containers/restart` +### `POST /api/host/containers/up` +### `POST /api/host/containers/down` +### `POST /api/host/containers/restart` Body: @@ -211,7 +218,7 @@ Success response: { "ok": true, "service": "chat", "status": "running" } ``` -### `GET /admin/containers/stats` +### `GET /api/host/containers/stats` Returns live Docker container resource usage (CPU, memory, network I/O) for managed services. Each entry is one JSON object from `docker compose stats --format json --no-stream`. @@ -234,7 +241,7 @@ Error responses: - `500 docker_error` -- `docker compose stats` failed. - `500 parse_error` -- Failed to parse stats output. -### `GET /admin/containers/events` +### `GET /api/host/containers/events` Returns recent Docker engine events (container start/stop/restart/die) filtered to managed services. @@ -260,7 +267,7 @@ Error responses: - `500 docker_error` -- `docker events` failed. - `500 parse_error` -- Failed to parse events output. -### `GET /admin/network/check` +### `GET /api/host/network/check` Checks inter-container connectivity by probing each core service health endpoint from the host admin process. @@ -281,7 +288,7 @@ Response: ## Addon Management -### `GET /admin/addons` +### `GET /api/host/addons` Returns all available addons with enabled status. @@ -297,7 +304,7 @@ Response: } ``` -### `POST /admin/addons` +### `POST /api/host/addons` Enable or disable an addon. @@ -322,7 +329,7 @@ Error responses: - `404 not_found` -- Addon name is not a built-in optional service. - `500 internal_error` -- Failed to update addon state on disk. -### `GET /admin/addons/:name` +### `GET /api/host/addons/:name` Returns detail for a single addon. @@ -344,7 +351,7 @@ Error responses: - `404 not_found` -- Addon name is not a built-in optional service. -### `POST /admin/addons/:name` +### `POST /api/host/addons/:name` Enable or disable a specific addon. @@ -374,10 +381,10 @@ Error responses: Automation task files live under `~/.openpalm/knowledge/tasks/` and are owned by AKM. -### `GET /admin/automations/catalog` +### `GET /api/host/automations/catalog` Lists available automation tasks from `~/.openpalm/knowledge/tasks/`. Portal addons -are managed via `/admin/addons` and Compose profiles. +are managed via `/api/host/addons` and Compose profiles. Response: @@ -393,10 +400,10 @@ Response: `source` is `"remote"` when loaded from a cloned registry repo, `"bundled"` when using build-time bundled stack assets. -### `POST /admin/automations/catalog/install` +### `POST /api/host/automations/catalog/install` Install a registry automation. Portal addons are managed via -`POST /admin/addons/:name`. +`POST /api/host/addons/:name`. Body: @@ -421,7 +428,7 @@ Error responses: - `400 invalid_input` -- Invalid name, type is not `"automation"`, item not found in registry, or item already installed. -### `POST /admin/automations/catalog/refresh` +### `POST /api/host/automations/catalog/refresh` Refreshes the registry index from the configured registry source. @@ -435,10 +442,10 @@ Error responses: - `500 registry_sync_error` — Refresh failed. -### `POST /admin/automations/catalog/uninstall` +### `POST /api/host/automations/catalog/uninstall` Uninstall a registry automation. Portal addons are managed via -`POST /admin/addons/:name`. +`POST /api/host/addons/:name`. Body: @@ -460,7 +467,7 @@ Response: ## Automations -### `GET /admin/automations` +### `GET /api/host/automations` Lists all automation configs from `~/.openpalm/knowledge/tasks/`. @@ -478,7 +485,7 @@ Response: "action": { "type": "http", "method": "POST", - "path": "/admin/...", + "path": "/api/host/...", "url": null, "content": null, "agent": null @@ -490,7 +497,7 @@ Response: } ``` -### `POST /admin/automations/:name/run` +### `POST /api/host/automations/:name/run` Manually trigger an automation. The admin spawns `akm tasks run ` directly; execution logs are written to `${OP_HOME}/data/akm/cache/tasks/logs//` and history @@ -510,7 +517,7 @@ Error responses: - `404 not_found` -- Automation is not installed in `knowledge/tasks/`. - `500 internal_error` -- `akm tasks run` exited non-zero. -### `GET /admin/automations/:name/log` +### `GET /api/host/automations/:name/log` Returns recent execution log lines from `${OP_HOME}/data/akm/cache/tasks/logs//` (newest first). @@ -531,7 +538,7 @@ Response: ## Configuration Endpoints -### `GET /admin/config/validate` +### `GET /api/host/config/validate` Run the in-house key-presence and secret-audit checks against non-secret `knowledge/env/stack.env`, resolved Compose config, and `knowledge/secrets/`. @@ -571,24 +578,24 @@ When validation finds issues: ## Artifact and Audit APIs -### `GET /admin/artifacts` +### `GET /api/host/artifacts` ```json { "artifacts": [{ "name": "compose", "sha256": "...", "generatedAt": "...", "bytes": 1234 }] } ``` -### `GET /admin/artifacts/manifest` +### `GET /api/host/artifacts/manifest` ```json { "manifest": [{ "name": "compose", "sha256": "...", "generatedAt": "...", "bytes": 1234 }] } ``` -### `GET /admin/artifacts/:name` +### `GET /api/host/artifacts/:name` - Allowed names: `compose`. - Returns `text/plain` and may include `x-artifact-sha256` header. -### `GET /admin/audit?limit=&source=` +### `GET /api/host/audit?limit=&source=` Query parameters: @@ -604,7 +611,7 @@ Query parameters: ## Installed Services -### `GET /admin/installed` +### `GET /api/host/installed` ```json { @@ -619,7 +626,7 @@ The UI (`@openpalm/ui`) is independently versioned and distributed via npm, not as a GitHub release asset. These endpoints let the operator browser and CLI discover available UI versions and install a specific one. -### `GET /admin/versions/ui` +### `GET /api/host/versions/ui` Lists published `@openpalm/ui` npm versions for the admin UI build picker. Returns newest-first (by npm publish time); a 404 from the registry (package not @@ -666,7 +673,7 @@ Fields: Up to 20 versions are returned. -### `GET /admin/versions/releases` +### `GET /api/host/versions/releases` Lists GitHub platform release tags (newest first). Used to display the platform release history in the admin UI. UI build information is **not** included — UI @@ -693,9 +700,9 @@ On error: Note: The `hasUiBuild` field that previously appeared on each release entry has been removed; UI builds are now sourced independently from the `@openpalm/ui` -npm package via `GET /admin/versions/ui`. +npm package via `GET /api/host/versions/ui`. -### `POST /admin/ui-version` +### `POST /api/host/ui-version` Seeds a specific `@openpalm/ui` npm version (or dist-tag) into `data/ui/`. The build is downloaded from the npm registry, integrity-verified (sha512, fail- @@ -712,7 +719,7 @@ Body: - `tag` (required) — An `@openpalm/ui` npm version (e.g. `"0.11.0-rc.2"`) or dist-tag (e.g. `"latest"`, `"next"`). **This is no longer a GitHub platform - release tag**; use `GET /admin/versions/ui` to list installable versions. + release tag**; use `GET /api/host/versions/ui` to list installable versions. Must match `^[a-zA-Z0-9._\-]+$`. Response: @@ -731,7 +738,7 @@ Error responses: ## Local Provider Detection -### `GET /admin/providers/local` +### `GET /api/host/providers/local` Probes well-known local LLM provider endpoints to detect which are running. Requires admin auth. @@ -760,7 +767,7 @@ Response: Manage secrets via the detected secret backend (env-file or pass-based). -### `GET /admin/secrets` +### `GET /api/host/secrets` Lists secret entry names (values are never returned in full). @@ -782,7 +789,7 @@ Response: } ``` -### `POST /admin/secrets` +### `POST /api/host/secrets` Set or update a secret value. @@ -809,7 +816,7 @@ Error responses: - `400 invalid_key` -- Key fails `validatePassEntryName` validation. - `500 internal_error` -- Failed to write secret. -### `DELETE /admin/secrets` +### `DELETE /api/host/secrets` Delete a secret entry. @@ -830,7 +837,7 @@ Error responses: - `400 bad_request` -- `key` query parameter missing. - `500 internal_error` -- Failed to remove secret. -### `POST /admin/secrets/generate` +### `POST /api/host/secrets/generate` Generate a random secret and store it under the given key. @@ -860,7 +867,7 @@ Error responses: ## OpenCode Management -### `GET /admin/opencode/status` +### `GET /api/host/opencode/status` Returns whether the OpenCode process is reachable. @@ -878,7 +885,7 @@ When unreachable: { "status": "unavailable", "url": "http://localhost:4096/" } ``` -### `GET /admin/opencode/model` +### `GET /api/assistant/model` Returns the current model from OpenCode's live config. @@ -894,7 +901,7 @@ Error responses: - `503 opencode_unavailable` -- OpenCode is not reachable. -### `POST /admin/opencode/model` +### `POST /api/assistant/model` Update the active model. Persists to the assistant OpenCode config and attempts live-apply via OpenCode's config API. @@ -924,7 +931,7 @@ Error responses: - `400 bad_request` -- `model` is missing or empty. - `500 internal_error` -- persisting the OpenCode model selection failed. -### `GET /admin/opencode/providers` +### `GET /api/host/opencode/providers` Lists all OpenCode providers with auth status and available models. @@ -948,7 +955,7 @@ Response: } ``` -### `GET /admin/opencode/providers/:id/auth` +### `GET /api/host/opencode/providers/:id/auth` Poll an OAuth authorization session for a provider. @@ -971,7 +978,7 @@ Error responses: - `400 bad_request` -- `pollToken` missing or provider ID mismatch. - `404 not_found` -- Poll session not found or expired. -### `POST /admin/opencode/providers/:id/auth` +### `POST /api/host/opencode/providers/:id/auth` Start an auth flow for a provider (API key or OAuth). @@ -1014,7 +1021,7 @@ Error responses: unsupported provider, or invalid `methodIndex`. - `500 internal_error` -- Failed to write API key to the user env. -### `GET /admin/opencode/providers/:id/models` +### `GET /api/host/opencode/providers/:id/models` Lists available models for a specific provider. @@ -1241,7 +1248,7 @@ Error responses: ## Logs -### `GET /admin/logs` +### `GET /api/host/logs` Retrieves Docker Compose service logs via `docker compose logs`. diff --git a/docs/technical/artifact-delivery-pattern.md b/docs/technical/artifact-delivery-pattern.md new file mode 100644 index 000000000..83c8ee562 --- /dev/null +++ b/docs/technical/artifact-delivery-pattern.md @@ -0,0 +1,102 @@ +# Artifact Delivery Pattern + +> **As built 2026-07-07**. This documents the current runtime delivery model used by OpenPalm artifacts. + +## Rule + +OpenPalm delivers updatable runtime content as npm artifacts with explicit version resolution. Runtime installs never fall back to `latest` in production. + +There are two strategies: + +| Strategy | Use when | Resolution | +|---|---|---| +| Exact pin | API/data compatibility or seeded-file hashes must stay in lockstep | `OP_*_VERSION` override -> `PLATFORM_VERSION`/paired image version -> hard error | +| Reviewed package manifest | Tooling that can advance independently within a managed package.json | Pinned or ranged dependency in the baked `package.json`, applied by `bun install`/`bun update` | + +## Current Exact-Pin Artifacts + +| Artifact | Package | Installed by | Resolution chain | +|---|---|---|---| +| Host UI build | `@openpalm/ui` | Host control plane seeding/updater | Host-side UI logic; not a container entrypoint artifact | +| Client app | `@openpalm/client` | Assistant container entrypoint | `OP_CLIENT_VERSION` -> `PLATFORM_VERSION` -> hard error | +| Skeleton seed | `@openpalm/skeleton` | Assistant + guardian entrypoints; CLI local dep for repo/npm installs | `OP_SKELETON_VERSION` -> `PLATFORM_VERSION` or guardian package version -> hard error/paired default | +| Guardian package | `@openpalm/guardian` | Guardian thin-host entrypoint | `OP_GUARDIAN_NPM_VERSION` -> `GUARDIAN_VERSION` -> hard error | + +## Current Reviewed Tool Package Pattern + +| Runtime | Source | Install action | +|---|---|---| +| Assistant tools | `/opt/openpalm/tools/package.json` with host bind overlay | `bun update --cwd /opt/openpalm/tools --production` | +| Guardian tools | `/opt/openpalm/tools/package.json` with host bind overlay | `bun install --cwd /opt/openpalm/tools --production` | + +This is deliberately different from the exact-pin artifacts. Tools are governed by an editable package manifest, not by one env var per tool. + +## Skeleton Resolution Chain + +The shipped skeleton source resolution chain is: + +1. `OPENPALM_REPO_ROOT` -> repo `packages/skeleton/` +2. `OPENPALM_SKELETON_DIR` -> Electron bundled extraResources skeleton +3. `require.resolve('@openpalm/skeleton/package.json')` -> installed package dir +4. source-relative repo fallback when running from source +5. npm download path when no local source exists + +The Electron bundled skeleton remains intentional so a fresh desktop install can seed offline. + +## Release Contract + +The release workflow stamps versioned manifests through `scripts/set-version.mjs` and `scripts/bump-unit.mjs`. + +For the platform unit this means: + +- root, lib, skeleton, guardian, cli, ui, client, electron, and admin-tools package versions are stamped together +- `packages/cli/package.json` keeps an exact `@openpalm/skeleton` pin equal to the platform version +- `@openpalm/client` publishes alongside `@openpalm/ui` +- the npm regression guard treats `@openpalm/skeleton` and `@openpalm/guardian` as dual-owned packages + +That exact CLI skeleton pin matters because npm-installed CLI builds resolve the seeding skeleton through their own dependency tree. + +## Runtime-Specific Delivery Paths + +### Host UI + +- Resolved into `OP_HOME/data/ui` +- Updated by the host control plane +- Supervised by Electron or `openpalm admin` + +### Harness Localhost Client App + +- Resolved into `OP_HOME/data/client` +- Served on the stable host-local origin `http://127.0.0.1:${OP_HOST_CLIENT_PORT:-3890}/chat` +- Opened by `openpalm app` and preferred by Electron when healthy + +### Assistant Container Client App + +- Installed into `/opt/openpalm/client` +- Served from the package's `bin/serve.mjs` +- Published on `${OP_CLIENT_PORT:-3810}` externally and port `3000` internally +- Seeded with `runtime-config.json` containing one locked default connection + +### Hosted PWA + +- Same static `@openpalm/client` build +- Served from the hosted origin used by current tests/docs: `https://app.openpalm.dev` +- Requires guardian TLS + CORS for remote instance connections + +## Failure Policy + +- Version resolution failure is loud: missing version source is an error. +- Install failure after version resolution is tolerant only where the entrypoint already has an on-disk artifact to keep using. +- No runtime path silently substitutes `latest`. + +## Related Files + +| File | Role | +|---|---| +| `scripts/set-version.mjs` | Shared manifest-stamping helper | +| `scripts/bump-unit.mjs` | Release-unit version computation and stamping | +| `.github/workflows/release.yml` | Release DAG | +| `packages/lib/src/control-plane/ui-assets.ts` | Host UI artifact delivery | +| `packages/lib/src/control-plane/client-assets.ts` | Harness localhost client artifact delivery | +| `containers/assistant/entrypoint.sh` | Assistant-container runtime install path | +| `containers/guardian/entrypoint.sh` | Guardian thin-host runtime install path | diff --git a/docs/technical/consolidated-stack-env.md b/docs/technical/consolidated-stack-env.md index b9cca34f0..8dffaf993 100644 --- a/docs/technical/consolidated-stack-env.md +++ b/docs/technical/consolidated-stack-env.md @@ -39,6 +39,7 @@ baked layer. One file should answer "what is this stack, and at what versions?". | `OP_IMAGE_TAG` | host — selects all server images | yes (already) | | layout (`OP_LAYOUT_VERSION`) | host — migration harness | yes (new) | | UI build (`OP_UI_VERSION`) | host — npm, independent of image | yes (new) | +| client app (`OP_CLIENT_VERSION`) | host — npm, installed by the assistant container at startup (added with `@openpalm/client`, Phase 5 of `ui-runtime-modes-plan.md`) | optional (empty = the image's `PLATFORM_VERSION`; never `latest`) | | enabled addons (`OP_ENABLED_ADDONS`) | host — compose profiles | yes (new; was `stack.yml`) | | OpenCode / akm-cli / bun / gws | **build-time, baked into the image** | **no (deferred)** — fixed by `OP_IMAGE_TAG` | diff --git a/docs/technical/environment-and-mounts.md b/docs/technical/environment-and-mounts.md index 7f584660b..2d31a818a 100644 --- a/docs/technical/environment-and-mounts.md +++ b/docs/technical/environment-and-mounts.md @@ -93,10 +93,21 @@ Ports and networks: | Item | Value | |---|---| -| Container port | `4096` | -| Host bind | `${OP_ASSISTANT_BIND_ADDRESS:-127.0.0.1}:${OP_ASSISTANT_PORT:-3800}` | +| Container port | `4096` (OpenCode), `3000` (chat client co-process) | +| Host bind | `${OP_ASSISTANT_BIND_ADDRESS:-127.0.0.1}:${OP_ASSISTANT_PORT:-3800}` (OpenCode) | +| Client host bind | `${OP_CLIENT_BIND_ADDRESS:-${OP_BIND_ADDRESS:-127.0.0.1}}:${OP_CLIENT_PORT:-3810}` (chat client) | | Networks | `assistant_net` | +The chat client co-process serves the static `@openpalm/client` build (P5d, +#510). It binds `0.0.0.0` **inside** the container on port `3000`; host +exposure is governed solely by the compose mapping above, which defaults to +loopback and honors the global `OP_BIND_ADDRESS` policy with a per-service +`OP_CLIENT_BIND_ADDRESS` override. At startup the entrypoint writes a +`runtime-config.json` beside the build with one locked default connection +pointing the browser at the host-published OpenCode URL +(`http://127.0.0.1:${OP_ASSISTANT_PORT:-3800}`, full-URL override via +`OP_CLIENT_DEFAULT_ASSISTANT_URL`). + Key env: | Variable | Value / source | Purpose | @@ -110,11 +121,20 @@ Key env: | `AKM_CACHE_DIR` | `/opt/akm/cache` | AKM cache directory | | `AKM_DATA_DIR` | `/opt/akm/data` | AKM durable data directory | | `OP_UID` / `OP_GID` | `stack.env` | Direct runtime uid/gid mapping | +| `OP_CLIENT_VERSION` | `stack.env` (empty = image `PLATFORM_VERSION`) | Exact-pin override for the `@openpalm/client` artifact the entrypoint installs | +| `OP_SKELETON_VERSION` | `stack.env` (empty = image `PLATFORM_VERSION`) | Exact-pin override for the `@openpalm/skeleton` artifact the assistant entrypoint installs | +| `OP_ASSISTANT_PORT` | `stack.env` (default `3800`) | Host-published OpenCode port; used to build the client's default connection URL | +| `OP_CLIENT_DEFAULT_ASSISTANT_URL` | `stack.env` (optional) | Full-URL override for the client's locked default connection | +| `OP_CLIENT_HOST_PORT` | compose-derived from `OP_CLIENT_PORT` | Host-published assistant-container client port used to add OpenCode CORS origins | +| `OP_HOST_CLIENT_PORT` | `stack.env`/host env (default `3890`) | Host-local client app/PWA port used by `openpalm app` and Electron; also added to OpenCode CORS origins | +| `OP_CLIENT_CORS_ALLOWED_ORIGINS` | `stack.env` (optional) | Extra comma-separated exact origins passed to OpenCode `--cors` for custom client deployments | +| `OP_BIND_ADDRESS` / `OP_ASSISTANT_BIND_ADDRESS` / `OP_CLIENT_BIND_ADDRESS` | compose env interpolation | Passed through so the entrypoint can detect explicit LAN exposure and widen OpenCode CORS only for that opt-in path | Notes: - The assistant has no Docker socket mount. - The assistant reads user secrets via `akm env:user` — there is no `/etc/vault/` container mount. +- If both the assistant and assistant-container client are explicitly bound off loopback, the entrypoint allows browser client origins for the LAN path. Wildcard host binds (`0.0.0.0`/`::`) cannot reveal the operator's chosen LAN hostname to the container, so the entrypoint adds OpenCode `--cors "*"` only in that already-LAN-exposed mode. - The entrypoint starts as root only long enough to normalize permissions and optional SSH setup, then drops privileges. ### Guardian @@ -151,6 +171,7 @@ Key env: | `GUARDIAN_AUDIT_PATH` | `/opt/openpalm/logs/guardian-audit.log` | Audit log path | | `PORTAL__SECRET_FILE` | `/run/secrets/portal__secret` | Portal principal seed secret file | | `GUARDIAN_CONTENT_VALIDATION` | `0` | Enable opt-in, fail-closed content validation of inbound messages | +| `GUARDIAN_CORS_ALLOWED_ORIGINS` | empty | Comma-separated exact browser origins allowed on guardian direct ingress CORS responses | | `GUARDIAN_MODERATION_URL` | `http://127.0.0.1:4097` | Local OpenCode moderator endpoint | | `GUARDIAN_MODERATION_PORT` | `4097` | Loopback port the entrypoint starts the moderator on | | `GUARDIAN_MODERATION_THRESHOLD` | `3` | Heuristic risk score at/above which a message escalates to the model | @@ -195,6 +216,7 @@ Key env (host process, not container): | Variable | Value / source | Purpose | |---|---|---| | `PORT` | `OP_HOST_UI_PORT` or `3880` | Admin HTTP listen port | +| `OP_HOST_CLIENT_PORT` | host env (default `3890`) | Stable localhost client-app/PWA origin used by `openpalm app` and Electron's preferred client chat URL | | `OP_HOME` | resolved from host env | OpenPalm home directory | | `OP_UI_LOGIN_PASSWORD` | `$OP_HOME/knowledge/secrets/op_ui_login_password` | Operator admin password promoted into the host admin process environment | | `OP_ALLOW_REMOTE_SETUP` | unset (`0`) | When `1`/`true`/`yes`: bind `0.0.0.0`, allow any Host/same-origin, and permit remote access to the setup wizard. Off by default (loopback-only). | @@ -236,7 +258,13 @@ These variables are consumed by Compose and service env blocks. | `OP_UID`, `OP_GID` | Runtime UID/GID for bind-mounted file ownership | | `OP_IMAGE_NAMESPACE`, `OP_IMAGE_TAG` | Image selection | | `OP_HOST_UI_PORT` | Admin UI host port (default `3880`); the admin UI runs as a host process, not a container | +| `OP_HOST_CLIENT_PORT` | Stable host-local client app/PWA port for `openpalm app` and Electron (default `3890`); intentionally separate from the assistant container's `OP_CLIENT_PORT` | | `OP_ASSISTANT_BIND_ADDRESS`, `OP_ASSISTANT_PORT` | Assistant host bind | +| `OP_CLIENT_BIND_ADDRESS`, `OP_CLIENT_PORT` | Assistant chat-client co-process host bind (default `127.0.0.1:3810`) | +| `OP_CLIENT_VERSION` | Exact-pin override for the `@openpalm/client` artifact installed in the assistant container | +| `OP_SKELETON_VERSION` | Exact-pin override for the `@openpalm/skeleton` artifact installed in the assistant container (and used by the guardian thin-host entrypoint when set) | +| `OP_CLIENT_DEFAULT_ASSISTANT_URL` | Full-URL override for the chat client's locked default connection | +| `OP_CLIENT_CORS_ALLOWED_ORIGINS` | Extra exact browser origins to allow when the assistant launches OpenCode | | `OP_CHAT_BIND_ADDRESS`, `OP_CHAT_PORT` | Chat addon host bind | | `OP_API_BIND_ADDRESS`, `OP_API_PORT` | API addon host bind | | `OP_VOICE_BIND_ADDRESS`, `OP_VOICE_PORT` | Voice addon host bind | diff --git a/docs/technical/phase-5-completion-guide.md b/docs/technical/phase-5-completion-guide.md new file mode 100644 index 000000000..c1133463b --- /dev/null +++ b/docs/technical/phase-5-completion-guide.md @@ -0,0 +1,239 @@ +# Phase 5 Completion Guide — client split (P5c → P5e) + +**Date:** 2026-07-06 +**Branch:** `claude/ui-runtime-modes-phases-1-4` +**Status:** HISTORICAL — Phase 5 completed in full (P5a–P5e + final sweep, all gates passed) on 2026-07-07. This document is preserved as the working record; for current remaining work see `ui-runtime-modes-plan.md` §12. +**Plan:** `docs/technical/ui-runtime-modes-plan.md` (§6.9–§6.11, Phase 5, §8 rules, §1 simplicity guardrails) +**Decision record:** `docs/technical/ui-client-split-assessment.md` +**Tracking issues:** #555 (client extraction), #510 (assistant-container Slice A) + +This file is the handoff for finishing Phase 5. It records exactly what is done, +what remains, the working discipline, and how to resume the interrupted workflow. + +--- + +## 1. What is DONE on this branch + +Phases 1–4 (+1.5) of the plan are complete (see plan §7/§10 — marked DONE with +as-built notes). For Phase 5: + +### P5a — `packages/ui-kit` ✅ (gate passed 3/3 after 1 fix round) + +- Raw-source private workspace package: `components/common/` (~21), `components/icons/` + (64+), theme tokens (`src/lib/theme/tokens.css`). No build step; consuming apps compile it. +- Fix-round outcomes now enforced by tests: `Toast.svelte` decoupled from voice-state + (ui-kit is presentational only); `svelte-check`-based `check` script + tsconfig added + (0 errors / 0 warnings, 261 files). +- Hygiene test guarantees ui-kit imports no `$lib/api`, `$lib/server`, `@openpalm/lib`, + or chat/connections stores. +- Key commits: `93fadfd` (extraction), `c7d53ab` (fix round). + +### P5b — `packages/client` ✅ (gate passed; 1 blocking finding found and fixed) + +`@openpalm/client` — the unprivileged chat/connections static SPA (plan §6.11): + +- **Build:** SvelteKit 2 + Svelte 5, `adapter-static` SPA (`ssr=false`), consumes + `@openpalm/ui-kit`. `build` → `svelte-kit sync && vite build && stamp-version` + (stamps `.openpalm-client-version`). Root scripts: `client:build`, `client:check`, + `client:test` wired in root `package.json`. +- **Transport** (`src/lib/transport/index.ts`): ONE module — direct fetch to a + guardian/OpenCode base URL; Basic (username default `openpalm`) / Bearer; + `credentials:'omit'` (no cookies ever); session list/create; message send; SSE frame + parser (UTF-8 split-chunk safe); never-throws health probe mapped to the host app's + RemoteStatus vocabulary. Injectable fetch for tests. +- **Connections** (`src/lib/connections/`): `ConnectionEntry` per plan §6.6; raw + IndexedDB with a storage abstraction + in-memory twin for tests; `runtime-config.json` + boot seeding (locked default connection; user selections and locked entries protected + on re-seed); offline-readable; `secrets.ts` holds per-connection credentials. +- **Views:** `/chat`, `/connections`, `/connections/new`; landing: 0 connections → + `/connections/new`, else `/chat` (`src/lib/resolve-landing.ts`). +- **Static server** (`bin/serve.mjs`): zero-dependency; loopback default (`--host` only + where policy gates exposure); SPA fallback; serves `runtime-config.json` from beside + the build; path-traversal-contained (verified with hostile requests); returns 400 on + malformed percent-escapes (gate finding — fixed in `c036bba`, pinned by + `tests/serve.test.ts`). +- **Purity, enforced:** zero runtime `dependencies`; no `src/lib/server/`; no + `@openpalm/lib` anywhere; `tests/purity.test.ts` greps the BUILT bundle for + `@openpalm/lib` and `/api/host` markers and fails on a missing build rather than skipping. +- **Tests:** 67 pass / 0 fail (transport request shaping, SSE, health, store CRUD + + locked/active semantics, seeding idempotency, offline read, landing, purity, serve + resilience). `client:check`: 0 errors / 0 warnings (312 files). +- Key commits: `424d091` + `e8e67b1` (red tests), `a049f06` (transport/store), + `93cebe8` (app/serve/wiring), `c6f38a4` + `c036bba` (gate fix). + +### Gate provenance for P5b (honesty note) + +Three review lenses ran: simplicity (block → serve.mjs crash), correctness (block → +same single finding; everything else verified incl. test-honesty diff between red and +impl commits), plan/security (agent died at the spend limit AFTER verifying purity, +traversal, cookie hygiene and signaling green; its two residual checks — no runtime +deps / no `src/lib/server`, and the `private` flag — were completed manually). +The single blocking finding is fixed and test-pinned. Gate: **PASS**. + +--- + +## 2. Carry-forward findings (non-blocking, assign to the right sub-phase) + +1. **CI does not run the new suites** — neither root `test` nor `ci.yml` includes + `client:test` / `client:check` / ui-kit check. → **P5e item 2/3** (already in spec). +2. **Chat streaming is partial** — the SSE parser is built and tested but the chat page + awaits the full JSON response; live event subscription + history are labeled + follow-up in the page. → chat-parity follow-up (before deleting `packages/ui` chat; + not a P5c blocker). +3. **`pickStorage()` async failure gap** — only synchronous `indexedDB` access errors + fall back to memory; an async `open()` failure (e.g. Firefox private mode) caches a + rejected boot promise. → small fix, fold into P5c or the finalize sweep. +4. **Locked-entry lifecycle** — a locked connection removed from a future + `runtime-config.json` can never be deleted by the user. → fold into P5d (the + only writer of runtime-config.json) — decide: prune locked entries absent from the + current config on seed. +5. **`createConnectionStore(options: { storage: unknown })`** weakens typing at the one + construction site. → trivial, any sub-phase. +6. **`packages/client/package.json` has `private: true`** — must flip to publishable in + **P5e** when it joins the npm publish DAG (mirror `@openpalm/ui`). + +--- + +## 3. Working discipline (unchanged from Phases 1–4) + +- **Test-first per sub-phase:** red tests committed before implementation + (`test(p5x): red tests for #NNN`); genuine reds must fail for the right reason; + characterization/hygiene tests labeled as such. Implementer never modifies red tests + (exception: genuinely wrong test — fix + justify). +- **Gate per sub-phase:** three adversarial reviewers — (1) correctness + test honesty, + (2) plan conformance + §8 security invariants (loopback defaults for every new + listener; client never bundles `@openpalm/lib` or holds host credentials; capabilities + server-enforced in `packages/ui`; harness contract untouched), (3) simplicity + scope + (non-technical-user bias; no heavy deps; no drive-bys). Block → fix round → re-review; + max 2 fix rounds; do not build the next sub-phase on a blocked gate. +- **Commits:** conventional style, reference #555 (or #510 for container work), end with: + + ``` + Co-Authored-By: Claude Fable 5 + Claude-Session: https://claude.ai/code/session_01FetR7BttT1xBrA8fBwhui9 + ``` + + Workflow agents never push; the supervising session pushes at sub-phase boundaries. + +### Environment parity rule (judge every suite against this — container is uid-0 root, no docker daemon, Playwright downloads proxy-blocked) + +| Suite | GREEN means | +|---|---| +| `bun run ui:check` / `client:check` / ui-kit `check` | 0 errors, 0 warnings (hard) | +| UI vitest (`cd packages/ui && npm run test:unit -- --run`) | ≥1082 passed, 0 failed in the node project; exit 1 from the 30 browser-project `*.svelte.vitest.ts` files (chromium blocked) is EXPECTED | +| `bun test --cwd packages/client` | 67+ pass, 0 fail (no env excuses) | +| `bun test --cwd packages/cli` | exactly 1 known fail (install-flow root-ownership under uid 0) | +| `bun test --cwd packages/lib` | exactly 17 known uid-0 fails (operator-ids ×8, volume-ownership ×3, ownership-reconcile ×5, rollback chmod-0400 ×1) + 10 docker skips | +| `bun run --cwd packages/electron test` | 88+ pass, 0 fail | +| `bun run lint` | no errors (16 pre-existing warnings + 1 info are baseline) | + +Any NEW failure beyond this set is a regression the sub-phase must fix. Never "fix" the +known-failing tests. + +--- + +## 4. Remaining work + +### P5c — harness serves the client (#555) + +1. **lib (`packages/lib`):** client-build resolution + seeding mirroring + `ui-assets.ts` — `resolveClientBuildDir` / `seedClientBuild` / + `checkAndUpdateClientBuild` for `@openpalm/client` (npm tarball integrity-verified + via the existing `npm-bundle-updater`; `OPENPALM_REPO_ROOT` dev override included; + version-stamp preference logic like the UI's). Thin sibling functions — do NOT fork + the updater. +2. **CLI (`packages/cli`):** the supervisor that serves the UI also starts the client + static server (`bin/serve.mjs` from the resolved client build) on + `DEFAULT_CLIENT_PORT=3890`, loopback-only, supervised/respawned like the UI child. + Both `openpalm admin` and the default serve path start it. Non-fatal if the client + build is absent (log + skip). +3. **Electron (`packages/electron`):** main.ts starts the same client child after the + UI server; when setup is complete, `resolveInitialUrl` loads + `http://127.0.0.1:3890/chat`, FALLING BACK to the 3880 chat if the client child + failed (keep the fallback dumb). `HARNESS_CONTRACT_VERSION` untouched — this is + spawn-env/child work; keep `harness-contract-drift` tests green. +4. `packages/ui` chat is NOT deleted (parity confirmation needs a real browser — record + as follow-up with carry-forward #2). + +*Red tests:* lib resolve/seed (follow `ui-assets` test patterns); CLI spawn env/args + +skip-when-absent; Electron initial-URL preference + fallback (follow `main.test.ts` +patterns); drift tests stay green (characterization). + +### P5d — assistant-container serves the client (#510 Slice A) + +1. `containers/assistant/entrypoint.sh`: `install_runtime_artifacts` pulls + `@openpalm/client` (`OP_CLIENT_VERSION` → `PLATFORM_VERSION` → hard error; keep the + landed warn-and-continue when a previous artifact exists; skeleton pull unchanged). +2. Replace `start_ui` with `start_client`: write `runtime-config.json` beside the build + FIRST (locked default connection pointing the BROWSER at the published OpenCode URL — + default `http://127.0.0.1:${OP_ASSISTANT_PORT:-3800}`, override + `OP_CLIENT_DEFAULT_ASSISTANT_URL`), then run `bin/serve.mjs` on `OP_CLIENT_PORT` + (default 3000, bind 0.0.0.0 inside the container). This deletes the old + `OPENCODE_API_URL` wiring bug path entirely. +3. Compose (`packages/skeleton/system/stack/`): publish the client port on the assistant + service behind the existing `OP_BIND_ADDRESS` policy (mirror `OP_ASSISTANT_PORT`; + loopback default). Wire `OP_CLIENT_PORT`/`OP_CLIENT_VERSION` into stack env plumbing. +4. Dockerfile: verify no `@openpalm/ui` co-process remnants; `PLATFORM_VERSION` arg + already wired. +5. Decide carry-forward #4 (locked-entry pruning) here — the entrypoint owns + runtime-config.json. +6. Docs: new env vars → `docs/technical/environment-and-mounts.md` / + `consolidated-stack-env.md`. + +*Red tests:* version-resolution (extend however `install_runtime_artifacts` is tested +today); repo test asserting no `OPENCODE_API_URL` export and no `@openpalm/ui` install +in entrypoint; compose assertion (yaml parse) for the port mapping + bind policy. +**No docker daemon here** — static verification only (`bash -n`, config asserts); say so +honestly in notes. + +### P5e — release integration (#555) + +1. Publish DAG (`platform-release.yml` — read it first): add `@openpalm/client` + (exact-pin, mirror `@openpalm/ui` publish/stamp; **flip `private: true` off** — + carry-forward #6). Verify nothing publishes `@openpalm/ui-kit`. +2. Root `package.json`: `client:check`/`client:test` + ui-kit check into the root + check/test aggregates. **Wire them into `ci.yml`** (carry-forward #1). +3. CI purity gate: ensure `tests/purity.test.ts` (dist grep) actually runs in CI's test + invocation and fails builds on violation. +4. Docs: `docs/technical/release-architecture.md` artifact table; plan §1 table row for + client marked landed; `OP_CLIENT_VERSION`/`OP_CLIENT_PORT` in env/release docs. + +*Red tests:* repo-hygiene test that root aggregates reference the client package; +YAML-parse test that the release workflow publishes `@openpalm/client` and not ui-kit. + +### Final sweep + +Run every suite (parity table above) + `client:build` + purity; fix only trivial +breakage; update `docs/technical/ui-runtime-modes-plan.md` §7/§10 marking Phase 5 DONE +with as-built notes (including: `packages/ui` chat NOT deleted — parity deferred; +Slice B settings shim not built); commit; push. + +--- + +## 5. How to resume + +**Option A — resume the workflow (preferred).** The interrupted run caches everything +through P5b's test author: + +``` +Workflow({ + scriptPath: "/root/.claude/projects/-home-user-openpalm/bd3d6dbd-40c3-5cff-8000-895d2d80f22b/workflows/scripts/ui-client-split-phase-5-wf_458bb49a-1ed.js", + resumeFromRunId: "wf_458bb49a-1ed" +}) +``` + +Caveats: the P5b implementer + gate reviewers were NOT journaled (they died / ran +out-of-band), so on resume they re-run — they will find the red tests already green and +should verify + return quickly; their re-review is redundant-but-harmless extra safety. +The workflow script's phases P5c/P5d/P5e run as specced (§4 above matches the script). +The session must push at each sub-phase boundary (workflow agents never push) — re-arm +the hourly check-in pattern via `send_later`. + +**Option B — manual (this session or a fresh one).** Execute §4 sub-phase by sub-phase +under the §3 discipline: red tests → implement → 3-lens gate (spawn three reviewer +agents with the lens prompts in §3) → fix rounds → push → next. + +**Watch out:** the monthly spend limit killed three agents on 2026-07-06 (two +implementers, one reviewer). If an agent dies mid-sub-phase: commit any uncommitted WIP +immediately (label it WIP, push), then resume — WIP commits are how P5b survived two +interruptions with zero lost work. diff --git a/docs/technical/release-architecture.md b/docs/technical/release-architecture.md index 9a1e480f2..e33bcd150 100644 --- a/docs/technical/release-architecture.md +++ b/docs/technical/release-architecture.md @@ -1,6 +1,6 @@ # Release Architecture -> **Last updated 2026-06-21** against the live codebase at 0.12.22. +> **Last updated 2026-07-06** against the live codebase at 0.12.52. ## Goals @@ -18,7 +18,7 @@ Each unit has a single canonical version anchor. All packages/images within a un | Unit | Artifacts | Canonical version anchor | |---|---|---| -| `platform` | @openpalm/lib (npm), openpalm CLI (npm + binaries), @openpalm/ui (npm), @openpalm/skeleton (npm), @openpalm/guardian (npm) | root `package.json` (max with npm-published) | +| `platform` | @openpalm/lib (npm), openpalm CLI (npm + binaries), @openpalm/ui (npm), @openpalm/client (npm), @openpalm/skeleton (npm), @openpalm/guardian (npm) | root `package.json` (max with npm-published) | | `portals` | @openpalm/discord-portal + @openpalm/slack-portal (npm), optional `openpalm/portal` Docker image (`include_images=true`) | `portals/discord/package.json` | | `assistant` | `openpalm/assistant` Docker image | `containers/assistant/VERSION` | | `guardian` | @openpalm/guardian (npm), @openpalm/skeleton (npm), optional guardian Docker image | `packages/guardian/package.json` (max with npm-published) | @@ -106,11 +106,14 @@ For `unit=all`, `bump` determines the version increment (patch/minor/major all v ### `platform` -Publishes: @openpalm/skeleton → @openpalm/lib → (@openpalm/guardian, openpalm CLI, @openpalm/ui) → CLI binaries → optional Electron. +Publishes: @openpalm/skeleton → @openpalm/lib → (@openpalm/guardian, openpalm CLI, @openpalm/ui) → CLI binaries → optional Electron. @openpalm/client publishes in parallel (exact-pin, `needs-build`, like @openpalm/ui) — it deliberately has **no** @openpalm/lib ordering dependency because the client never bundles the host library (ui-runtime-modes-plan.md §8.10; `@openpalm/ui-kit` is inlined from the workspace at build time and is **never published**). -Stamps: root, lib, skeleton, guardian, cli, ui, electron, admin-tools package.json files + `scripts/setup.sh` `SCRIPT_VERSION` + `scripts/setup.ps1` `$ScriptVersion`. +Stamps: root, lib, skeleton, guardian, cli, ui, client, electron, admin-tools package.json files + `scripts/setup.sh` `SCRIPT_VERSION` + `scripts/setup.ps1` `$ScriptVersion`. -Preflight: full test suite (`bun run test`, `ui:check`, `ui:test:unit`, `electron:test`). +The shared `scripts/set-version.mjs` helper is also responsible for rewriting the CLI's exact `@openpalm/skeleton` dependency pin to the release version during this stamp. That keeps the published CLI's bundled skeleton source in lockstep with `PLATFORM_VERSION`. + + +Preflight: full test suite (`bun run client:build` then `bun run test` — which includes the client-bundle purity gate — plus `ui:check`, `ui-kit:check`, `client:check`, `ui:test:unit`, `electron:test`). ### `portals` @@ -150,7 +153,7 @@ Preflight: `bun run electron:test`. The **complete** release. Stamps every unit's files simultaneously to the same version (any bump type or explicit override) and publishes everything, flag-free: -- **npm**: `@openpalm/{lib,ui,guardian,skeleton}` + `openpalm` (CLI) + `@openpalm/{discord,slack}-portal` +- **npm**: `@openpalm/{lib,ui,client,guardian,skeleton}` + `openpalm` (CLI) + `@openpalm/{discord,slack}-portal` - **Docker**: `openpalm/{assistant,guardian,portal}` (+ `:latest` for stable) - **Electron** installers (mac/linux/win) + CLI native binaries - Per-unit tags + bare `X.Y.Z` summary tag + GitHub release diff --git a/docs/technical/testing-stack-in-isolation.md b/docs/technical/testing-stack-in-isolation.md index 4d3fe9152..c90f531d9 100644 --- a/docs/technical/testing-stack-in-isolation.md +++ b/docs/technical/testing-stack-in-isolation.md @@ -58,12 +58,18 @@ Or use the dev-setup script (which seeds `.dev/` with dev ports) and manually ad ### 2. Start the Docker stack (assistant + guardian) +Managed compose files live under `.dev/system/stack/`; `.dev/config/stack/` +contains only the user-owned `custom.compose.yml` overlay. + ```bash bun run dev:build # or with test ports: OP_ASSISTANT_PORT=4800 \ docker compose --project-directory . \ - -f .dev/config/stack/core.compose.yml \ + -f .dev/system/stack/core.compose.yml \ + -f .dev/system/stack/services.compose.yml \ + -f .dev/system/stack/portals.compose.yml \ + -f .dev/config/stack/custom.compose.yml \ -f compose.dev.yml \ --env-file .dev/knowledge/env/stack.env \ --project-name openpalm-test \ 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-route-map.md b/docs/technical/ui-route-map.md new file mode 100644 index 000000000..a606c3df4 --- /dev/null +++ b/docs/technical/ui-route-map.md @@ -0,0 +1,147 @@ +# UI Route Map + +**Date:** 2026-07-06 (Phase 4 of `ui-runtime-modes-plan.md`) +**Package:** `packages/ui` (`@openpalm/ui` — the host app) +**Status:** current truth after the Phase 4 control-plane split. `/admin/*` is +a **dead namespace** (router 404, no alias): pages live at `/host`, privileged +JSON endpoints at `/api/host/*`, assistant-owned settings at +`/api/assistant/*`, session lifecycle at `/api/auth/*`. Phase 5 moves the +chat + connections views into `packages/client`. + +Surfaces (plan §1): **Host** = host control plane (stack lifecycle, secrets, +privileged ops), **Assistant** = chat against the active connection + +assistant-owned settings, **Connection** = connection management, **Entry** = +landing/auth/first-run plumbing shared by all surfaces. + +## Landing resolution + +Every document navigation to `/` (and the legacy `/splash` path) is redirected +by `hooks.server.ts` to the landing resolved by `resolveLanding(ctx, +launchState)` (`src/lib/resolve-landing.ts`, plan §6.5): + +| Condition (in precedence order) | Landing | +|---|---| +| `host:setup` capability + migration pending | `/attention` | +| `host:setup` + local `not_installed` (no accessible connection) | `/setup` | +| `host:setup` + local `not_installed` (accessible connection exists, #440) | `/chat` | +| `host:setup` + local `setup_incomplete` | `/setup` | +| `host:setup` + local `installed_offline` | `/host` | +| `host:setup` + local `installed_broken` | `/host?tab=diagnostics` | +| `host:setup` + local `running` | `/chat` | +| no `host:setup`, `assistant-container` mode | `/chat` | +| no `host:setup`, `pwa-static` mode, 0 connections | `/connections/new` | +| no `host:setup`, `pwa-static` mode, ≥1 connection | `/chat` | +| anything else | `/chat` | + +The launch-routing guard fires **before** the auth guard, so `/` and stale +`/splash` bookmarks never bounce through `/login` first. `/splash` no longer +exists as a route; the path keeps redirecting to the resolved landing for this +release only. + +## Guards (defined in `src/hooks.server.ts` unless noted) + +- **auth** — document navigations (GET + `Accept: text/html`) without a valid + `op_session` cookie redirect to `/login?redirectTo=…`. API/data requests are + left to the endpoint's own JSON 401. +- **host capability gate** — `/host/*` pages require the server to advertise + the `host:*` capability set (`electron-host`/`host-ui`); otherwise redirects + to `/chat`. UX only — the security boundary is per-endpoint. +- **requireAdmin()** — per-endpoint session check in `+server.ts` handlers + (`$lib/server/helpers.js`); JSON 401, never an HTML redirect. +- **requireCapability(cap)** — per-endpoint server-side capability check + (plan §8.5; `hasCapability()` in components is UX only). Every + `/api/host/*` endpoint carries one (enforced by + `src/routes/api/host/guard-hygiene.vitest.ts`), as does every + `/api/assistant/*` endpoint — a valid admin session in a mode without the + capability is still refused with 403 `capability_not_available`. +- **setup localhost** — SEC-4: `/setup` + `/api/setup/*` are unauthenticated + before first-run completes but restricted to loopback clients (unless + `OP_ALLOW_REMOTE_SETUP`); after completion, re-runs require admin auth. +- **host/origin** — SEC-1/SEC-2 Host-header allowlist + Origin check apply to + every request; host admin stays loopback-only (plan §8.3). + +`/admin/*` has **no guard and no alias**: hooks let it fall through to the +router, which 404s because the route tree is deleted (plan §6.4). + +## Page routes + +| Path | Surface | Guard | Notes | +|---|---|---|---| +| `/` | Entry | launch-routing (pre-auth) | Never renders: hooks (document nav) + `+page.server.ts` (client-side nav) redirect to the resolved landing | +| `/splash` | Entry | launch-routing (pre-auth) | **Route removed** in Phase 3; the path 302s to the resolved landing for this release | +| `/attention` | Entry | auth | Migration/blocking surface split out of `/splash`; landing when `migration.status === 'pending'` (no producer yet) | +| `/login` | Entry | public | Password login; posts to `/api/auth/login`, which issues the `op_session` cookie | +| `/setup` | Host | setup localhost | First-run wizard; `?rerun=1` after completion requires admin auth | +| `/chat` | Assistant | auth | Stillness chat; own corner chrome (hides the navbar); imports domain clients directly, never the `$lib/api.js` barrel (#555) | +| `/advanced` | Assistant | auth | Embedded OpenCode web UI; mounts `ChatNavbar` (chat chrome composition) | +| `/connections` | Connection | auth (page) + `connections:manage` (its API) | Connection manager; mounts `ChatNavbar`; `?new=1` opens the add form | +| `/connections/new` | Connection | auth | pwa-static "no connections yet" landing; 302 alias to `/connections?new=1` | +| `/host` | Host | host capability gate + auth | Dashboard (tabbed); mounts the chat-free `Navbar` shell (#555); honors `?tab=diagnostics` (Systems tab) | +| `/admin`, `/admin/*` | — | none | **404.** Dead namespace since Phase 4 (the Phase 2 `/admin/endpoints` → `/connections` alias is gone too) | + +## API routes + +| Namespace | Surface | Guard | Endpoints | +|---|---|---|---| +| `/api/runtime` | Entry | **public** | GET server runtime context — the contract-version handshake (plan §6.4) | +| `/health` | Entry | public | Liveness probe | +| `/guardian/health` | Entry | public | Guardian reachability probe | +| `/api/auth/{login,logout,session}` | Entry | public (login) / session | Session lifecycle. Deliberately **outside** `/api/host` — a capability guard on login would lock assistant-container out before it could authenticate | +| `/api/setup/*` | Host | setup localhost | 19 endpoints: `status`, `system-check`, `recommend`, `detect-providers`, `current-config`, `complete`, `deploy-status`, `retry-deploy`, `host-status`, `import-host`, `models/[provider]`, `ollama-profiles`, `voice-profiles`, `opencode/{ensure,status,providers,auth/[provider],provider/[provider]/oauth/{authorize,callback}}` | +| `/api/connections`, `/api/connections/[id]`, `/api/connections/active` | Connection | requireAdmin + `requireCapability('connections:manage')` | Connection CRUD + activation (Phase 2, #486 — stays here, not under `/api/host`) | +| `/api/assistant/*` | Assistant | requireAdmin + `requireCapability('assistant-settings:read'/'write')` | Assistant-owned settings — editable from assistant-container: `persona` (config/assistant/persona.md), `akm` (config/akm/config.json), `model` (OpenCode default/small model) | +| `/api/host/*` | Host | requireAdmin + `requireCapability('host:…')` per endpoint | Privileged host control plane (see below); 403 `capability_not_available` in assistant-container/pwa-static even with a valid session | +| `/api/speak`, `/api/transcribe` | Assistant | requireAdmin | Voice TTS/STT relays | +| `/api/electron/update-status` | Host | (Electron harness) | Control-plane self-update status | +| `/proxy/assistant/[...path]` | Assistant | requireAdmin (same-origin cookie) | Same-origin assistant broker; resolves the active connection per request. Host app only (plan §6.4) | + +`/api/host/*` JSON endpoints (each carries requireAdmin + the listed +`requireCapability` guard): + +- Stack (`host:setup`/`host:stack:*`/`host:recovery`/`host:logs`): `install`, + `uninstall`, `update`, `unlock`, `health`, `logs`, `config/validate`, + `backups`, `stack` (project name + bind address — the host half of the old + assistant tab) +- Containers (`host:containers`): `containers/{list,up,down,restart,pull,stats,events}` +- Versions (`host:updates`): `versions`, `versions/{latest,releases,ui}`, `ui-version` +- Add-ons (`host:addons`): `addons`, `addons/[name]`, `addons/[name]/credentials` +- Automations (`host:stack:*`/`host:logs`): `automations`, `automations/[name]/{file,log,run}` +- AKM host-level (`host:containers`, `host:akm-sharing`, `host:stack:read`): + `akm/{health,health-report,stats,reindex,host-sharing}`, + `akm/embedding/{detect,test}` — the assistant-scoped AKM config lives at + `/api/assistant/akm` +- Voice (`host:stack:*`): `voice` +- Providers (`host:secrets`): `providers`, `providers/[id]`, + `providers/{host-status,import-host}`, `providers/assistant-clis`, + `providers/assistant-clis/[toolId]/use-provider`, + `providers/oauth/{start,finish}`, `providers/oauth/[providerId]/callback`, + `opencode/providers/[id]/{auth,models}` +- Secrets (`host:secrets`): `secrets`, `secrets/[name]`, `secrets/user-env`, `secret-notice` + +## Chrome composition (#555) + +| Module | Used by | Imports chat modules? | +|---|---|---| +| `lib/components/chrome/Navbar.svelte` | `/host` | **No** — brand + capability-driven chat/host buttons (`runtimeContext.routes` + `hasCapability()`) + theme toggle | +| `lib/components/chrome/ChatNavbar.svelte` | `/advanced`, `/connections` | Yes — composes the shell with `EndpointSwitcher`, `SessionPicker`, `VoiceControl`, `ModeSwitch` | +| `/chat` corner chrome | `/chat` | Chat page renders its own minimal corner chrome and hides the navbar | + +Hygiene is enforced by unit tests: +`src/lib/features-admin-hygiene.vitest.ts` (no component reads the legacy +admin flag), `src/lib/components/chrome/chrome-untangle-hygiene.vitest.ts` +(the chrome the admin surface mounts imports no chat modules), +`src/routes/chat/page-imports.vitest.ts` (chat page never imports the +`$lib/api.js` barrel), `src/routes/api/host/guard-hygiene.vitest.ts` (every +`/api/host/*` endpoint calls `requireCapability`), and +`src/lib/api/admin-paths-hygiene.vitest.ts` (no client code calls or links a +dead `/admin` path). + +## Testing + +The landing matrix is unit-tested in `src/lib/resolve-landing.vitest.ts`; the +hooks routing in `src/hooks.server.landing.vitest.ts`, +`src/hooks.server.vitest.ts` and `src/hooks.server.admin-404.vitest.ts` (the +Phase 4 dead-namespace contract). Playwright coverage lives in +`e2e/setup-guard.pw.ts` (guard/landing smoke) and the `*.stack.ts` suites; +browsers cannot be downloaded in the sandboxed CI container, so the Playwright +suites run only in environments with a real browser + Docker. diff --git a/docs/technical/ui-runtime-modes-plan.md b/docs/technical/ui-runtime-modes-plan.md index 6c81c382a..791dcc9f2 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; Phase 5 as-built recorded 2026-07-07) +**Status:** RATIFIED — Phases 0–5 (+1.5) landed; Phases 6–8 remain — see **§12 Remaining work** for the complete handoff (verified against code 2026-07-07, post-merge with main/#554) **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), #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 +> 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 — **landed** (#555/#510) | `@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/client` since Phase 5 — was `@openpalm/ui`; + `@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 +**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.) -``` -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 -``` +### 6.7 `@openpalm/skeleton` package — **[as-landed]** -**`tools.json` schema:** +Landed in #508. What shipped (differences from the draft noted): -```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" } -] -``` - -`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. - -**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,129 @@ 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]** + +**Landed in `containers/assistant/entrypoint.sh`:** `install_runtime_artifacts()` +(exact-pin resolution, npm install of client + skeleton, tools via `bun update`) and — +since Phase 5 (P5d) — `start_client()` (static co-process via the client's `serve.mjs`, +`OP_CLIENT_PORT` default 3000; replaces the pre-Phase-5 `start_ui()`/`OP_UI_PORT`). +Boot order: `install_runtime_artifacts → … → start_client → start_opencode`. + +**Known gaps (tracked in #510) — all CLOSED by Phase 5 (P5d):** + +1. ~~The co-process port is not published in any compose file.~~ Compose now publishes + `OP_CLIENT_PORT` (in-container default 3000) behind the existing bind-address policy. +2. ~~`start_ui` exports `OPENCODE_API_URL`, but the UI reads `OP_OPENCODE_URL`.~~ Moot + after the re-scope: `start_ui` is gone; `start_client` writes `runtime-config.json` + beside the build with one **locked default connection** pointing at the OpenCode port + as published on the host (`http://127.0.0.1:${OP_ASSISTANT_PORT:-3800}` — the browser, + not the container, dials it), overridable via `OP_CLIENT_DEFAULT_ASSISTANT_URL`. +3. ~~No mode env / skeleton-seed for assistant-scoped config.~~ The static client needs + no mode env — single-connection behavior comes from the seeded locked connection + (`runtime-config.json`), not a server flag. + +**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` + +``` +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) +``` -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. +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/`. +### Phase 1 — RuntimeContext v2, no UI change (#509) — ✅ DONE -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 +**As-built:** `ServerRuntimeContext` + `resolveCapabilities()` landed inside the existing +`lib/server/features.ts` (filename kept; `FeatureFlags` replaced). The interim +`features.admin` alias was carried through Phases 1–3 and removed entirely in Phase 4. **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,31 +585,64 @@ 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) (#556) — ✅ DONE + +**As-built:** as designed; how-it-works docs now record the three admin access paths +(Electron, `openpalm admin`, dev-only `OP_ENABLE_ADMIN=1`) and drop the stale +`openpalm ui serve` reference. -**Closes #486.** +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`. + +### Phase 2 — Connection management out of `/admin` (#486) — ✅ DONE + +**As-built:** as designed; the `/admin/endpoints` → `/connections` redirect alias shipped +here as planned but was deleted with the rest of `/admin/*` in Phase 4 (§6.4 no-alias rule). 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 +### Phase 3 — Capability-driven landing and navigation — ✅ DONE + +**As-built:** `/splash` became `/attention` (the other landing targets already existed as +routes); `docs/technical/ui-route-map.md` produced. New Playwright smoke tests were not +added — the existing e2e stack suites were updated for the new routes instead. 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; capability-driven nav; chat chunk free of admin API clients (verify bundle); route map doc exists. -Acceptance: Electron healthy → chat; assistant-container → chat; PWA no-connections → `/connections/new`. +### Phase 4 — Split Host and Assistant Control Planes (#555) — ✅ DONE -### Phase 4 — Split Host and Assistant Control Planes +**As-built:** session auth also moved to `/api/auth/{login,logout,session}`; the assistant +namespace split into `/api/assistant/{persona,model,akm}`; `/admin/*` 404s via router +fall-through (route tree deleted, deliberately no hooks alias per §6.4). 1. Move `/admin` → `/host`. No alias — remove immediately. With <10 installs there's no upgrade audience to protect. 2. Split Assistant tab: host stack settings (`host:stack:write`) vs. assistant settings (`assistant-settings:write`). @@ -551,48 +651,115 @@ 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 - -*Depends on Phases 0, 1, 4.* - -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. - -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. - -### Phase 6 — PWA mode - -*Depends on Phases 1, 2, 3.* - -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. +### Phase 5 — Extract `packages/client` + `packages/ui-kit` (#555); assistant-container serves it (#510) — ✅ DONE + +*Depends on Phases 2–4 (the untangling steps).* + +**As-built (2026-07-07, sub-phases P5a–P5e; details in +`docs/technical/phase-5-completion-guide.md`):** + +- **P5a — `packages/ui-kit`:** `components/common/`, `icons/`, and theme tokens moved + into a raw-source, unpublished workspace package with its own + `svelte-check --fail-on-warnings` gate (the raw sources' only TS coverage). + `Toast.svelte` was decoupled from voice-state during the move (voice errors are + mirrored from app code) so ui-kit holds no store with server/app assumptions. +- **P5b — `packages/client`:** adapter-static SPA (`/chat`, `/connections`, + `/connections/new`) with one transport module (request shaping, SSE parsing, health + probe), an IndexedDB connection store seeded from an optional `runtime-config.json`, + its own `resolveLanding`, a zero-dependency `bin/serve.mjs` static server (loopback + by default), and a build-time version stamp. A **bundle purity test** + (`tests/purity.test.ts`, runs in CI) greps every file of the built bundle for the + forbidden markers `@openpalm/lib` and `/api/host` (and fails loudly on a missing + build) — §8.5/§8.10 enforced structurally, plus source hygiene (no lib dependency in + any group, no `src/lib/server/`). Phase 6 later added `@vite-pwa/sveltekit`. +- **P5c — harness serving:** lib gained `control-plane/client-assets.ts` + (resolve/seed/update, sibling of `ui-assets.ts`); the CLI serves the client build on + the **stable loopback port 3890** (`DEFAULT_CLIENT_PORT` in + `packages/cli/src/lib/ports.ts`, override `OP_HOST_CLIENT_PORT`; intentionally separate + from assistant-container `OP_CLIENT_PORT`); Electron spawns a client + server child and the window prefers the client chat, with a health probe falling back + to the host UI when the client build is absent or dead. +- **P5d — assistant container (#510):** entrypoint installs `@openpalm/client` + (exact pin `OP_CLIENT_VERSION` → `PLATFORM_VERSION` → hard error), writes + `runtime-config.json` with one locked default connection, and serves the static build + via `serve.mjs`; compose publishes `OP_CLIENT_PORT` behind the bind-address policy. + All three §6.9 gaps closed (see §6.9 as-built notes). Slice A only. +- **P5e — release:** item 5 below (landed as written). +- **Deferred, deliberately:** + - **`packages/ui` chat is NOT yet deleted** — the client's parity inside Electron has + not been confirmed, so per §6.11 the host app keeps its chat surface (Electron falls + back to it) until parity is verified in real use. Deletion is a follow-up. + - **Slice B (assistant-settings shim) is NOT built** — assistant-container mode is + chat-only against the single locked connection; `/api/assistant/*` writes from the + container's browser surface remain an optional future slice (§6.9). + +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. — **landed** + (P5e: `release.yml` `npm-client` job mirrors `npm-ui`, exact-pin/`needs-build`, + platform+all stamp/regression-guard/version-sync membership; the client-bundle + purity gate runs in CI; `@openpalm/ui-kit` stays unpublished). +6. (Optional Slice B, separate issue) settings shim for `/api/assistant/*` writes. + +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 (two install origins) (#511) + +*Depends on Phase 5.* + +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 + CORS workstream (#557) — NEW + +*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 +769,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 +783,52 @@ 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 | `.github/workflows/release.yml` (the plan's "platform-release.yml") | Add `@openpalm/client` — **landed** (Phase 5/P5e); skeleton pin advance check remains (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) ✅ DONE (#509) +Phase 1.5 (openpalm admin) ✅ DONE (#556) +Phase 2 (connections + untangle) ✅ DONE (#486) ──┐ +Phase 3 (landing + chrome untangle) ✅ DONE ─┼─► Phase 5 prerequisites met +Phase 4 (control plane split) ✅ DONE (#555) ──┘ +Phase 5 (client + ui-kit extraction, assistant-container) ✅ DONE (#555/#510) +Phase 6 (PWA, two install origins) ─ needs 5 (met) +Phase 6.5 (guardian TLS + CORS) ─ parallel to 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 — **complete**. Phase 5 is now **complete** as well +(P5a–P5e; see §7 as-built notes — `packages/ui` chat retained pending Electron parity +confirmation, Slice B shim not built). Phases 6–6.5 remain; they land in 0.13.0 only if +it stays light, otherwise they open 0.14.0. --- ## 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 +836,131 @@ 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 + +--- + +## 12. Remaining work — detailed handoff (verified 2026-07-07) + +Everything below was verified against the code on branch +`claude/ui-runtime-modes-phases-1-4` (PR #559) after merging `origin/main` +(commit `49bab70` + import fix `ac1c5ea`). Statuses here are checked facts, not +assumptions. + +### 12.1 Merge reconciliation (2026-07-07) + +`origin/main` brought PR #554 (chat voice/streaming UX: streaming markdown + +autoscroll, sentence-stream TTS, hands-free conversation mode, earcons, VAD, +barge-in, copy affordances, stop-generation, composer resilience) into +`packages/ui`'s chat. Conflicts were resolved by routing main's new icon imports +through `@openpalm/ui-kit` (`ChatInput`, `ChatMessage`, `VoiceStatusStrip`; +`IconWaves.svelte` relocated to ui-kit). All suites re-verified at parity +post-merge (ui:check 0/0 across 1,203 files; UI vitest 1,139/1,139 node-project). + +**Consequence:** the client app's chat copies did NOT receive these features — +the parity gap that gates deleting `packages/ui` chat grew substantially. See §12.2. + +### 12.2 Chat parity contract — gates deleting `packages/ui` chat (§6.11 rule 3) + +Measured delta: `packages/client/src/routes/chat/+page.svelte` is 328 LOC vs the +host chat's 1,597. Missing in the client (all present in `packages/ui` after #554): + +- streaming markdown rendering + the `$lib/chat/autoscroll.ts` module +- stop-generation control for in-flight turns +- copy affordances on messages/code blocks (`IconCopy`/`IconDone` in `ChatMessage`) +- composer resilience (IME guard, draft-while-sending, failed-send retry) +- the entire voice stack: `VoiceStatusStrip`, conversation mode, earcons, + VAD calibration, sentence-stream TTS, barge-in (`packages/ui/src/lib/voice/*`) +- session history browsing (`SessionPicker`) + +**Decision required before porting** (file as its own issue): +(a) full parity port — progressively move shared chat components into `ui-kit` +and port the voice stack into the client; heavy, and drags voice/media concerns +into the unprivileged client; or +(b) **recommended per §1 simplicity guardrails:** define client-chat parity as a +subset contract — text chat + streaming render + stop + copy + failed-send retry; +voice remains host-chat-only for now. Either way, `packages/ui` chat is deleted +only when the written contract is met AND Electron has been verified running the +client chat in a real browser. + +### 12.3 Phase 6 — PWA (#511). All prerequisites met. Work items: + +1. `@vite-pwa/sveltekit` in `packages/client`: manifest, icons (192/512 + + maskable), Workbox app-shell precache. SW rules: never cache credentialed + responses; `runtime-config.json` must be NetworkFirst (a stale locked + connection URL must not outlive a container reconfigure). +2. Localhost origin: the harness already serves the client on stable loopback + port 3890 (P5c). Add install affordances: Electron menu item, host-app + button, and an `openpalm app` CLI command that opens the browser there. +3. Hosted origin: CI deploy of the static build to the official URL + (`app.openpalm.dev`; hosting provider TBD). Client-side contract-version + handshake against `/api/runtime` with graceful degradation. +4. Pairing UX (§6.6): host app `/connections` mints QR + one-time code; client + `/connections/new` accepts paste-or-scan. Guardian side coordinates with + #435/#557. +5. Offline shell: the IndexedDB store landed in P5b — verify the offline boot + path end-to-end once the SW exists. + +### 12.4 Phase 6.5 — guardian TLS + CORS (#557) + +As specced in §7 Phase 6.5. Hard prerequisite for any phone → guardian +connection (secure-context + mixed-content rules). CORS allowlist must include +the hosted origin; the hosted client refuses plain-HTTP non-loopback targets +with an actionable deep-link to the TLS guide. Tailscale `ts.net` is the +recommended default; Caddy + user domain the alternative; installing a private +CA on phones is an explicit non-goal. + +### 12.5 Phase 7 — security hardening (updated for as-built state) + +Already covered by earlier phases: `/api/host/*` guard hygiene tests (Phase 4), +client bundle purity gate in CI (P5e), `serve.mjs` hostile-request tests (P5b). +Remaining: guardian CORS enforcement tests; SW caching-rule review (with Phase 6); +context-aware origin checks per mode; CSP review for BOTH apps — note +`packages/client/svelte.config.js` has no `kit.csp` yet (add during Phase 6); +verify the purity assertion also runs against the container-pulled artifact path. + +### 12.6 Phase 8 — release remainder + +- Verify the release process advances the CLI's pinned `@openpalm/skeleton` dep + in lockstep with `PLATFORM_VERSION` (§6.7 follow-up — still open). +- Replacement docs: `ui-runtime-modes.md` (successor to this plan) + + `artifact-delivery-pattern.md`. +- Release checklist: smoke-test electron-host, host-ui (`openpalm admin`), + assistant-container, and both PWA install origins. +- **Before merging PR #559:** one real-browser run of the 30 browser-project + `*.svelte.vitest.ts` files (`cd packages/ui && npm run test:unit`) — they + cannot execute in the dev container (Playwright browser download is + proxy-blocked); they are updated for the new routes and type-check clean. + +### 12.7 Open code-level items (each verified in code, with pointers) + +1. **Locked-entry pruning not implemented** — + `packages/client/src/lib/connections/index.ts`: `seedFromRuntimeConfig` is + upsert-only and `deleteConnection` rejects locked entries (~line 225), so a + locked entry removed from a later `runtime-config.json` is stuck forever. + Fix: prune locked entries absent from the current config during seed. + Owner: the entrypoint/store pair (#510 follow-up). +2. **`pickStorage()` async-failure gap** — only synchronous `indexedDB` access + errors fall back to memory; an async `open()` rejection (e.g. Firefox + private mode) caches a rejected boot promise. Add async fallback. +3. **`createConnectionStore(options: { storage: unknown })`** weakens typing at + the single construction site — tighten to `ConnectionStorage`. +4. **Slice B settings shim** (§6.9, optional): not built — assistant-container + mode is chat-only. Ship only when browser-editable assistant settings are + actually wanted. +5. Resolved for the record: CI wiring for client/ui-kit suites ✅ (P5e), + `@openpalm/client` `private` flag removed ✅ (P5e), `serve.mjs` + percent-escape hardening ✅ (P5b gate), `runtime-config.json` offline + resilience ✅ (`loadRuntimeConfig` never throws). diff --git a/docs/technical/ui-runtime-modes.md b/docs/technical/ui-runtime-modes.md new file mode 100644 index 000000000..0f5d9c866 --- /dev/null +++ b/docs/technical/ui-runtime-modes.md @@ -0,0 +1,121 @@ +# UI Runtime Modes + +> **As built 2026-07-07**. This replaces `ui-runtime-modes-plan.md` as the current-state reference. + +## Summary + +OpenPalm now ships one UI product as two artifacts: + +| Surface | Artifact | Runtime | Purpose | +|---|---|---|---| +| Host control plane | `@openpalm/ui` | Electron or `openpalm admin` host process | Host setup, lifecycle, logs, secrets, connections, assistant settings | +| Client app | `@openpalm/client` | Harness localhost server, assistant container static server, or hosted static origin | Chat, connection switching, installable PWA | + +`@openpalm/ui-kit` is a shared raw-source workspace package consumed at build time by both apps. It is not published. + +## Host Modes + +`packages/ui/src/lib/server/features.ts` computes the server runtime context from `OP_UI_HOST_MODE` with these shipped values: + +| `hostMode` | Served artifact | How it is entered | Server capabilities | +|---|---|---|---| +| `electron-host` | `@openpalm/ui` | Electron main process sets `OP_INSIDE_ELECTRON=1` / host mode env | Full host + assistant + connections surface | +| `host-ui` | `@openpalm/ui` | `openpalm admin` | Full host + assistant + connections surface without Electron IPC | +| `assistant-container` | `@openpalm/client` | Assistant container `start_client` co-process | Single locked connection chat surface; no host capabilities | +| `pwa-static` | `@openpalm/client` | Harness localhost app server or hosted static origin | Connection manager + chat; no host capabilities | + +## Client Display Modes + +The browser-side client context distinguishes: + +| `clientDisplayMode` | Detection | Effect | +|---|---|---| +| `electron` | Electron user agent | Full server-provided host capabilities remain visible | +| `standalone-pwa` | `matchMedia('(display-mode: standalone)')` | PWA chrome/behavior | +| `browser` | Default | Regular browser behavior | + +Capability resolution is centralized in the runtime-context store. The client artifact has no `host:*` capability types at all. + +## Routing And Capability Shape + +Current route baselines: + +| Mode | Primary routes | +|---|---| +| `electron-host` / `host-ui` | `/host`, `/chat`, `/connections`, `/setup` | +| `assistant-container` | `/chat` | +| `pwa-static` | `/chat`, `/connections` | + +Current namespaces: + +| Namespace | Served by | +|---|---| +| `/api/runtime` | Host UI and host-served runtime context | +| `/api/auth/*` | Host UI | +| `/api/connections/*` | Host UI | +| `/api/assistant/*` | Host UI | +| `/api/host/*` | Host UI only | +| `/proxy/assistant/*` | Host UI only | + +The assistant-container Slice A that shipped in Phase 5 is static-only. It serves `@openpalm/client` plus `runtime-config.json`; it does **not** ship the optional assistant-settings shim. + +## Localhost Client Origin + +There are two different client ports in the shipped system: + +| Port | Owner | Meaning | +|---|---|---| +| `3890` | Harness localhost client server | Stable host-local origin for `openpalm app` and Electron's preferred client chat URL | +| `3810` by default | Assistant container compose port (`OP_CLIENT_PORT`) | Host-published port for the assistant container's own static client co-process | + +The stable host-local origin is resolved by `packages/lib/src/control-plane/client-app-url.ts`: + +- default: `http://127.0.0.1:3890/chat` +- override: `OP_HOST_CLIENT_PORT` +- deliberate non-input: `OP_CLIENT_PORT` does **not** affect this origin + +That split is intentional. `OP_CLIENT_PORT` belongs to the assistant container artifact listener; `OP_HOST_CLIENT_PORT` belongs to the harness-served localhost app/PWA identity. + +## Assistant-Container Mode + +`containers/assistant/entrypoint.sh` installs runtime artifacts before booting OpenCode: + +- `@openpalm/client`: `OP_CLIENT_VERSION` -> `PLATFORM_VERSION` -> hard error +- `@openpalm/skeleton`: `OP_SKELETON_VERSION` -> `PLATFORM_VERSION` -> hard error +- tools: `bun update --cwd /opt/openpalm/tools --production` + +After install it writes `runtime-config.json` with one locked default connection pointing the browser at the host-published OpenCode URL: + +- default URL: `http://127.0.0.1:${OP_ASSISTANT_PORT:-3800}` +- full override: `OP_CLIENT_DEFAULT_ASSISTANT_URL` + +The static server itself binds `0.0.0.0` inside the container on port `3000`; Compose publishes it as `${OP_CLIENT_BIND_ADDRESS:-${OP_BIND_ADDRESS:-127.0.0.1}}:${OP_CLIENT_PORT:-3810}:3000`. + +## PWA Delivery Modes + +`packages/client` is an adapter-static SvelteKit app with `@vite-pwa/sveltekit` enabled. + +Supported install paths in the shipped code/docs: + +1. Localhost install from the harness-served client origin on `127.0.0.1:3890`. +2. Hosted static install from the canonical hosted origin used in tests/docs: `https://app.openpalm.dev`. + +The PWA build excludes `runtime-config.json` from precache and treats it as `NetworkFirst`. Credentialed remote responses are excluded from runtime caching. + +## Security Boundaries + +- Host admin remains loopback-only. +- The client artifact does not bundle `@openpalm/lib` and exposes no host APIs. +- Hosted-origin remote connections require HTTPS guardians and matching `GUARDIAN_CORS_ALLOWED_ORIGINS` entries. +- Assistant-container mode remains isolated from Docker and broad `OP_HOME` access. + +## Related Files + +| File | Role | +|---|---| +| `packages/ui/src/lib/server/features.ts` | Server runtime-context and host-mode resolution | +| `packages/lib/src/control-plane/client-app-url.ts` | Stable localhost client origin (`3890` / `OP_HOST_CLIENT_PORT`) | +| `packages/cli/src/commands/app.ts` | `openpalm app` entrypoint | +| `packages/cli/src/lib/client-server.ts` | Harness localhost client server | +| `containers/assistant/entrypoint.sh` | Assistant-container artifact install + `runtime-config.json` write | +| `packages/client/vite.config.ts` | PWA manifest and service-worker caching rules | diff --git a/package.json b/package.json index 715ebfa4b..fdcdf7b9c 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "packages/lib", "packages/portal-sdk", "packages/ui", + "packages/ui-kit", + "packages/client", "packages/skeleton", "packages/guardian", "packages/cli", @@ -24,6 +26,12 @@ "electron:dev": "bun run ui:build && bun run --cwd packages/electron bundle && OP_HOME=/tmp/openpalm-electron-dev OPENPALM_REPO_ROOT=\"$PWD\" bun run --cwd packages/electron start", "electron:dev:fast": "bun run --cwd packages/electron bundle && OP_HOME=/tmp/openpalm-electron-dev OPENPALM_REPO_ROOT=\"$PWD\" bun run --cwd packages/electron start", "ui:check": "bun run --cwd packages/ui check", + "ui-kit:check": "bun run --cwd packages/ui-kit check", + "client:dev": "bun run --cwd packages/client dev", + "client:build": "bun run --cwd packages/client build", + "client:check": "bun run --cwd packages/client check", + "client:test": "bun test --cwd packages/client", + "client:serve": "node packages/client/bin/serve.mjs", "ui:test": "cd packages/ui && npm test", "ui:test:unit": "cd packages/ui && npm run test:unit -- --run", "ui:test:e2e": "source scripts/load-test-env.sh && cd packages/ui && RUN_DOCKER_STACK_TESTS=1 RUN_LLM_TESTS=1 PW_ENFORCE_NO_SKIP=1 npm run test:e2e", @@ -51,10 +59,11 @@ "dev:build": "./scripts/dev-setup.sh --seed-env && docker compose --project-name openpalm-dev --project-directory . -f .dev/system/stack/core.compose.yml -f .dev/system/stack/services.compose.yml -f .dev/system/stack/portals.compose.yml -f .dev/config/stack/custom.compose.yml -f compose.dev.yml --env-file .dev/knowledge/env/stack.env up --build -d", "_dev:stack:note": "Intentional dev shortcut: 4-file compose list mirrors the CLI overlay discovery for the dev layout — MANAGED core/services/portals from .dev/system/stack/, USER custom from .dev/config/stack/. --project-name openpalm-dev is required to avoid colliding with a production stack under ~/.openpalm/.", "electron:test": "bun run --cwd packages/electron test", - "test": "bun test --no-orphans portals/discord portals/slack packages/portal-sdk packages/cli packages/lib packages/electron/admin-tools packages/guardian/ scripts/", + "test": "bun test --cwd packages/client && bun test --no-orphans portals/discord portals/slack packages/portal-sdk packages/cli/src packages/lib packages/ui-kit packages/electron/admin-tools packages/guardian/ scripts/", + "_test:note": "packages/client runs as a separate `bun test --cwd` invocation (first, so the purity gate always executes): Bun path filtering treats packages/cli as a string prefix of packages/client, so the aggregate command targets packages/cli/src to avoid re-running the client suite in the second process. The client purity test requires `bun run client:build` first — it fails loudly on a missing build.", "analysis:fta": "npx -y fta-cli . -c .fta.json --json | python3 -c \"import json,sys;d=sorted(json.load(sys.stdin),key=lambda x:x['fta_score'],reverse=True);c={};[c.__setitem__(f['assessment'],c.get(f['assessment'],0)+1) for f in d];s=[f['fta_score'] for f in d];print(f'\\n=== FTA Code Complexity Report ({len(d)} files) ===');print(f'Mean: {sum(s)/len(s):.1f} | Median: {sorted(s)[len(s)//2]:.1f} | Max: {max(s):.1f}');print();[print(f' {a}: {n}') for a,n in sorted(c.items(),key=lambda x:-x[1])];print(f'\\n=== Top 20 Most Complex Files ===');print(f\\\"{'Score':>7} {'Cyclo':>5} {'Lines':>5} {'Assessment':<20} File\\\");print('-'*100);[print(f\\\"{f['fta_score']:7.1f} {f['cyclo']:5d} {f['line_count']:5d} {f['assessment']:<20} {f['file_name']}\\\") for f in d[:20]];ni=[f for f in d if f['fta_score']>60];print(f'\\n=== Needs Improvement ({len(ni)} files) ===');[print(f\\\" {f['fta_score']:6.1f} {f['file_name']}\\\") for f in ni]\"", "analysis:fta:json": "npx -y fta-cli . -c fta.json --json", - "check": "bun run ui:check", + "check": "bun run ui:check && bun run ui-kit:check && bun run client:check", "lint": "biome lint .", "lint:fix": "biome lint --write .", "format": "biome format --write .", diff --git a/packages/cli/package.json b/packages/cli/package.json index 117bebde8..61eb33bbe 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -36,7 +36,7 @@ }, "dependencies": { "@openpalm/lib": ">=0.12.52 <1.0.0", - "@openpalm/skeleton": "0.12.18", + "@openpalm/skeleton": "0.12.52", "citty": "^0.2.1", "yaml": "^2.8.0" }, diff --git a/packages/cli/src/commands/admin.test.ts b/packages/cli/src/commands/admin.test.ts new file mode 100644 index 000000000..058fbc646 --- /dev/null +++ b/packages/cli/src/commands/admin.test.ts @@ -0,0 +1,454 @@ +/** + * 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: 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 package import is + * explicitly restored before dynamic imports because other aggregate CLI tests + * use mock.module('@openpalm/lib') and Bun module mocks can leak across files. + * + * 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, mock } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { renderUsage, runCommand, type CommandDef } from 'citty'; +import * as realLib from '../../../lib/src/index.ts'; +import 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 mainModuleUrl = new URL('../main.ts', import.meta.url).href; +const uiServerModuleUrl = new URL('../lib/ui-server.ts', import.meta.url).href; +const cliStateModuleUrl = new URL('../lib/cli-state.ts', import.meta.url).href; +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..'); + +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', + 'OPENPALM_REPO_ROOT', + 'OPENPALM_SKELETON_DIR', + 'OP_CLIENT_PORT', + 'OP_HOST_CLIENT_PORT', + 'OP_CLIENT_DEFAULT_ASSISTANT_URL', + 'OP_ASSISTANT_PORT' +] as const; +const savedEnv: Record = {}; +for (const key of SAVED_ENV_KEYS) savedEnv[key] = process.env[key]; +const tmpDirs: string[] = []; + +afterEach(() => { + mock.restore(); + restoreOpenPalmLib(); + 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 }); +}); + +function restoreOpenPalmLib(): void { + mock.restore(); + mock.module('@openpalm/lib', () => ({ ...realLib })); + mock.module(cliStateModuleUrl, () => ({ + ensureValidState: () => { + const state = realLib.createState(); + if (realLib.classifyLocalInstall(state.stackDir, state.homeDir) === 'not_installed') { + throw new Error('OpenPalm is not installed in this OP_HOME yet. Run `openpalm install` first.'); + } + state.artifacts = realLib.resolveRuntimeFiles(); + return state; + }, + resolveServeState: () => { + const state = realLib.createState(); + if (realLib.classifyLocalInstall(state.stackDir, state.homeDir) === 'not_installed') return state; + state.artifacts = realLib.resolveRuntimeFiles(); + return state; + }, + })); +} + +/** 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; + delete process.env.OPENPALM_SKELETON_DIR; + delete process.env.OP_CLIENT_PORT; + delete process.env.OP_HOST_CLIENT_PORT; + delete process.env.OP_CLIENT_DEFAULT_ASSISTANT_URL; + delete process.env.OP_ASSISTANT_PORT; + process.env.OPENPALM_REPO_ROOT = repoRoot; + 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 }> { + restoreOpenPalmLib(); + const state: { error?: unknown } = {}; + const mod = (await import(`${adminModuleUrl}?t=${Math.random()}`)) 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 () => { + restoreOpenPalmLib(); + const { mainCommand } = await import(`${mainModuleUrl}?t=${Math.random()}`) as { mainCommand: CommandDef }; + 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 () => { + restoreOpenPalmLib(); + const { mainCommand } = await import(`${mainModuleUrl}?t=${Math.random()}`) as { mainCommand: CommandDef }; + 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(realLib.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 } = {}; + restoreOpenPalmLib(); + const { startUIServer } = await import(`${uiServerModuleUrl}?t=${Math.random()}`) as { + startUIServer: (opts?: UIServerOptions) => Promise; + }; + 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 } = {}; + restoreOpenPalmLib(); + const { startUIServer } = await import(`${uiServerModuleUrl}?t=${Math.random()}`) as { + startUIServer: (opts?: UIServerOptions) => Promise; + }; + 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 + ); +}); 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/commands/app.test.ts b/packages/cli/src/commands/app.test.ts new file mode 100644 index 000000000..ebf6f7185 --- /dev/null +++ b/packages/cli/src/commands/app.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +beforeEach(() => { + delete process.env.OP_CLIENT_PORT; + delete process.env.OP_HOST_CLIENT_PORT; +}); + +afterEach(() => { + delete process.env.OP_CLIENT_PORT; + delete process.env.OP_HOST_CLIENT_PORT; +}); + +describe('openpalm app', () => { + it('resolves the stable localhost client origin on the default port', async () => { + const { resolveClientAppUrl } = await import('@openpalm/lib'); + + expect(resolveClientAppUrl()).toBe('http://127.0.0.1:3890/chat'); + }); + + it('uses OP_HOST_CLIENT_PORT and ignores OP_CLIENT_PORT collisions', async () => { + process.env.OP_CLIENT_PORT = '4810'; + process.env.OP_HOST_CLIENT_PORT = '4890'; + const { resolveClientAppPort, resolveClientAppUrl } = await import('@openpalm/lib'); + + expect(resolveClientAppPort()).toBe(4890); + expect(resolveClientAppUrl()).toBe('http://127.0.0.1:4890/chat'); + }); + + it('keeps the stable localhost origin when only OP_CLIENT_PORT is set', async () => { + process.env.OP_CLIENT_PORT = '4810'; + const { resolveClientAppPort, resolveClientAppUrl } = await import('@openpalm/lib'); + + expect(resolveClientAppPort()).toBe(3890); + expect(resolveClientAppUrl()).toBe('http://127.0.0.1:3890/chat'); + }); + + it('routes through the UI/client supervisor so the localhost app is served before opening', async () => { + const calls: UIServerOptions[] = []; + const mod = await import('./app.ts'); + + await mod.runAppCommand(async (options) => { + calls.push(options); + }); + + expect(calls).toEqual([{ openTarget: 'client' }]); + }); + + it('registers the app subcommand in the main command map', async () => { + const { mainCommand } = await import('../main.ts'); + const sub = (mainCommand.subCommands as Record Promise>).app; + expect(typeof sub).toBe('function'); + const cmd = (await sub()) as { meta?: { name?: string } }; + expect(cmd.meta?.name).toBe('app'); + }); +}); diff --git a/packages/cli/src/commands/app.ts b/packages/cli/src/commands/app.ts new file mode 100644 index 000000000..b560119d4 --- /dev/null +++ b/packages/cli/src/commands/app.ts @@ -0,0 +1,18 @@ +import { defineCommand } from 'citty'; +import { startUIServer, type UIServerOptions } from '../lib/ui-server.ts'; + +export async function runAppCommand( + start = (options: UIServerOptions) => startUIServer(options), +): Promise { + await start({ openTarget: 'client' }); +} + +export default defineCommand({ + meta: { + name: 'app', + description: 'Open the localhost OpenPalm app at the stable loopback client origin', + }, + async run() { + await runAppCommand(); + }, +}); diff --git a/packages/cli/src/commands/client-serve.ts b/packages/cli/src/commands/client-serve.ts new file mode 100644 index 000000000..2aa5f8064 --- /dev/null +++ b/packages/cli/src/commands/client-serve.ts @@ -0,0 +1,40 @@ +/** + * `openpalm client-serve` — run the @openpalm/client static server in the + * foreground (P5c, #555). This is the child process the `openpalm` / + * `openpalm admin` supervisor spawns when the CLI runs as a COMPILED binary + * (a compiled binary cannot execute a bare .mjs path the way `bun` can, so it + * re-invokes itself and imports the serve script in-process on the embedded + * runtime — the same pattern as `openpalm ui` / runUiBuild). + * + * The serve script (bin/serve.mjs, zero-dependency) reads PORT / HOST / + * OP_CLIENT_DIR from the environment; the supervisor pins HOST=127.0.0.1 and + * OP_CLIENT_DIR to the resolved build before spawning. + */ +import { defineCommand } from 'citty'; +import { existsSync } from 'node:fs'; +import { resolveClientBuildDir } from '@openpalm/lib'; +import { resolveClientServeScript } from '../lib/client-server.ts'; + +export default defineCommand({ + meta: { + name: 'client-serve', + description: + 'Run the client app static server in the foreground (no supervisor). ' + + 'This is the child process the bare `openpalm` supervisor spawns.', + }, + async run() { + // OP_CLIENT_DIR (set by the supervisor) pins the served build; a direct + // invocation falls back to the shared resolver. + const buildDir = process.env.OP_CLIENT_DIR ?? resolveClientBuildDir(); + const serveScript = resolveClientServeScript(buildDir); + if (!existsSync(serveScript)) { + console.error(`Client serve script not found at ${serveScript}`); + console.error('Run: bun run client:build'); + process.exit(1); + } + process.env.OP_CLIENT_DIR ??= buildDir; + // Importing the script starts the HTTP server; the listening socket keeps + // the process alive. + await import(serveScript); + }, +}); diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index 313f1de8b..d3ee7bde5 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -23,6 +23,7 @@ import { PLATFORM_VERSION, runDeploy, writeSystemEnv, + patchSecretsEnvFile, collectBindAddressWarnings, type SetupSpec, } from '@openpalm/lib'; @@ -308,20 +309,9 @@ async function runFileInstall(filePath: string, noStart: boolean, explicitImageT } const config = await parseConfigFile(filePath, await Bun.file(filePath).text()); - // Normalize old wrapped format: { spec: { version, capabilities }, capabilities: [...] } - // into flat format: { version, capabilities: {...}, connections: [...] } - if (config.spec && typeof config.spec === 'object') { - const spec = config.spec as Record; - // Old format had connections array as top-level "capabilities" - if (Array.isArray(config.capabilities)) config.connections = config.capabilities; - config.version = spec.version; - config.capabilities = spec.capabilities; - delete config.spec; - } - if (config.version !== 2) throw new Error('Setup config must be version 2. See example.spec.yaml for the format.'); - if (!config.capabilities || typeof config.capabilities !== 'object' || Array.isArray(config.capabilities)) { - throw new Error('Setup config must contain a "capabilities" object (llm, embeddings).'); + if ('spec' in config || 'capabilities' in config) { + throw new Error('Setup config must use the modern flat shape (`llm`, `embedding`, `security`, `connections`) — legacy `spec`/`capabilities` forms are no longer supported.'); } // A deliberate --version pins the image tag; thread it into the spec so @@ -342,6 +332,24 @@ async function runFileInstall(filePath: string, noStart: boolean, explicitImageT const result = await performSetup(config as unknown as SetupSpec); if (!result.ok) throw new Error(`Setup failed: ${result.error}`); + + // Persist intentional non-secret runtime overrides from the install shell so + // a later `openpalm start` reuses the same isolated project/port shape instead + // of falling back to the live-stack defaults. + const runtimeOverrides = Object.fromEntries( + [ + 'OP_PROJECT_NAME', + 'OP_ASSISTANT_PORT', + 'OP_HOST_UI_PORT', + 'OP_HOST_CLIENT_PORT', + 'OP_CLIENT_PORT', + ].flatMap((key) => { + const value = process.env[key]?.trim(); + return value ? [[key, value]] : []; + }), + ); + patchSecretsEnvFile(process.env.OP_HOME ?? resolveOpenPalmHome(), runtimeOverrides); + console.log('Setup complete.'); if (noStart) { console.log('Config written. Run `openpalm start` to start services.'); return; } await requireDocker(); diff --git a/packages/cli/src/commands/status.test.ts b/packages/cli/src/commands/status.test.ts index a3c296590..304288156 100644 --- a/packages/cli/src/commands/status.test.ts +++ b/packages/cli/src/commands/status.test.ts @@ -44,7 +44,7 @@ async function runStatusCommand(): Promise<{ stdout: string; stderr: string }> { describe('openpalm status — deriveLaunchStatus snapshot', () => { test('prints a valid LaunchStatus JSON when the stack is running', async () => { mock.module(libUrl, () => ({ - createState: () => ({ stackDir: '/fake/config/stack', homeDir: '/fake', configDir: '/fake/config', stashDir: '/fake/knowledge', dataDir: '/fake/data', workspaceDir: '/fake/workspace', services: {}, artifacts: { compose: '' }, artifactMeta: [] }), + ...realLib, classifyLocalInstall: () => 'installed', composePs: async () => ({ ok: true, @@ -81,7 +81,7 @@ describe('openpalm status — deriveLaunchStatus snapshot', () => { test('prints splash route when stack is not installed', async () => { mock.module(libUrl, () => ({ - createState: () => ({ stackDir: '/fake/config/stack', homeDir: '/fake', configDir: '/fake/config', stashDir: '/fake/knowledge', dataDir: '/fake/data', workspaceDir: '/fake/workspace', services: {}, artifacts: { compose: '' }, artifactMeta: [] }), + ...realLib, classifyLocalInstall: () => 'not_installed', composePs: async () => ({ ok: false, stdout: '', stderr: '', exitCode: 1 }), buildComposeOptions: () => ({}), @@ -113,7 +113,7 @@ describe('openpalm status — deriveLaunchStatus snapshot', () => { test('prints splash with localInstalledButUnhealthy when stack is offline', async () => { mock.module(libUrl, () => ({ - createState: () => ({ stackDir: '/fake/config/stack', homeDir: '/fake', configDir: '/fake/config', stashDir: '/fake/knowledge', dataDir: '/fake/data', workspaceDir: '/fake/workspace', services: {}, artifacts: { compose: '' }, artifactMeta: [] }), + ...realLib, classifyLocalInstall: () => 'installed', composePs: async () => ({ ok: true, stdout: '', stderr: '', exitCode: 0 }), buildComposeOptions: () => ({}), 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/client-server.test.ts b/packages/cli/src/lib/client-server.test.ts new file mode 100644 index 000000000..b68038423 --- /dev/null +++ b/packages/cli/src/lib/client-server.test.ts @@ -0,0 +1,285 @@ +// ── P5c RED TESTS (#555) — CLI serves the client on a stable loopback port ──── +// +// The supervisor that serves the UI (`openpalm` default serve path AND +// `openpalm admin`) must ALSO start the @openpalm/client static server +// (bin/serve.mjs from the resolved client build) on DEFAULT_CLIENT_PORT=3890, +// loopback-only, supervised/respawned like the UI child, and NON-FATALLY skip +// when no client build is present (plan Phase 5 item 3; phase-5 guide §4 P5c +// item 2). +// +// Pinned contract (these tests are the spec): +// • `DEFAULT_CLIENT_PORT` lives in src/lib/ports.ts (single source of the +// magic number, like DEFAULT_UI_PORT) and is 3890 — stable because the +// localhost PWA identity is origin-including-port (plan §6.10). +// • New module src/lib/client-server.ts exports: +// resolveClientServeScript(buildDir) → the serve script that travels with +// the resolved client build: join(buildDir, '..', 'bin', 'serve.mjs') +// (holds for BOTH channels: data/client/{build,bin} and +// $OPENPALM_REPO_ROOT/packages/client/{build,bin}). +// startClientServer(deps) → Promise with injectable deps +// (port, resolveBuildDir, existsSync, spawnFn, log/logError, sleep, +// stopTimeoutMs) so no real process is ever spawned in tests — same +// style as createCliUiSupervisor in ui-server.test.ts. +// • The spawned child's env carries the serve.mjs config contract +// (PORT / HOST / OP_CLIENT_DIR — see packages/client/bin/serve.mjs): +// HOST is 127.0.0.1 ALWAYS. There is no remote escape hatch: +// OP_ALLOW_REMOTE_SETUP must NOT loosen the client server's bind (the +// acceptance line: "loopback-only enforced for the client server"). +// • Absent build → log + skip, return null, spawn nothing, never throw and +// never exit (the UI must keep serving). +// • Supervision: an unexpected child exit respawns it; stop() SIGTERMs the +// child and suppresses any further respawn. +import { describe, expect, it, beforeEach, afterEach } from 'bun:test'; +import { join } from 'node:path'; +import * as ports from './ports.ts'; +import { startClientServer, resolveClientServeScript, resolveDefaultAssistantUrl } from './client-server.ts'; + +// ── fakes ───────────────────────────────────────────────────────────────────── + +interface FakeClientProc { + signals: string[]; + kill: (sig?: number | NodeJS.Signals) => void; + exited: Promise; + killed: boolean; + /** Resolve `exited` from the test to simulate the child dying. */ + die: (code: number) => void; +} + +function fakeClientProc(): FakeClientProc { + const signals: string[] = []; + let resolveExit!: (code: number) => void; + const exited = new Promise((r) => { resolveExit = r; }); + const proc: FakeClientProc = { + signals, + exited, + killed: false, + die: (code: number) => { resolveExit(code); }, + kill: (sig?: number | NodeJS.Signals) => { + signals.push(String(sig ?? 'SIGTERM')); + proc.killed = true; + resolveExit(0); // a killed fake dies immediately + }, + }; + return proc; +} + +interface SpawnRecord { + cmd: string[]; + env: Record; +} + +function harness(opts: { + /** One fake proc per spawn; the last repeats for any extra spawns. */ + procs?: FakeClientProc[]; + buildDir?: string; + /** false = the resolved client build is absent (skip-when-absent path). */ + present?: boolean; + port?: number; +}) { + const buildDir = opts.buildDir ?? '/op-home/data/client/build'; + const procs = opts.procs ?? [fakeClientProc()]; + const spawns: SpawnRecord[] = []; + const logs: string[] = []; + const runtimeConfigWrites: Array<{ path: string; assistantUrl: string }> = []; + const handlePromise = startClientServer({ + port: opts.port, + resolveBuildDir: () => buildDir, + existsSync: () => opts.present !== false, + spawnFn: (cmd: string[], o: { env: Record }) => { + spawns.push({ cmd: [...cmd], env: { ...o.env } }); + return procs[Math.min(spawns.length - 1, procs.length - 1)]; + }, + log: (...a: unknown[]) => { logs.push(a.map(String).join(' ')); }, + logError: (...a: unknown[]) => { logs.push(a.map(String).join(' ')); }, + resolveRuntimeConfigPath: () => '/op-home/data/client/runtime-config.json', + resolveAssistantUrl: () => process.env.OP_CLIENT_DEFAULT_ASSISTANT_URL || `http://127.0.0.1:${process.env.OP_ASSISTANT_PORT || '3800'}`, + writeRuntimeConfig: (path: string, assistantUrl: string) => { runtimeConfigWrites.push({ path, assistantUrl }); }, + sleep: () => Promise.resolve(), + stopTimeoutMs: 5, + }); + return { handlePromise, spawns, logs, buildDir, procs, runtimeConfigWrites }; +} + +const tick = () => new Promise((r) => setTimeout(r, 10)); + +// ── port constant ───────────────────────────────────────────────────────────── + +describe('DEFAULT_CLIENT_PORT', () => { + it('is exported from ports.ts and is 3890 (stable loopback PWA origin, plan §6.10)', () => { + expect((ports as Record).DEFAULT_CLIENT_PORT).toBe(3890); + }); +}); + +// ── serve script resolution ─────────────────────────────────────────────────── + +describe('resolveClientServeScript', () => { + it('resolves bin/serve.mjs as a SIBLING of the resolved build dir (both channels)', () => { + // data channel: OP_HOME/data/client/build → OP_HOME/data/client/bin/serve.mjs + expect(resolveClientServeScript('/op-home/data/client/build')) + .toBe(join('/op-home/data/client/bin', 'serve.mjs')); + // dev channel: repo packages/client/build → packages/client/bin/serve.mjs + expect(resolveClientServeScript('/repo/packages/client/build')) + .toBe(join('/repo/packages/client/bin', 'serve.mjs')); + }); +}); + +describe('resolveDefaultAssistantUrl', () => { + it('uses persisted stack env when process env does not override it', () => { + expect(resolveDefaultAssistantUrl({}, { OP_ASSISTANT_PORT: '4800' })).toBe('http://127.0.0.1:4800'); + expect(resolveDefaultAssistantUrl({}, { OP_CLIENT_DEFAULT_ASSISTANT_URL: 'https://assistant.example' })) + .toBe('https://assistant.example'); + }); + + it('lets process env override persisted stack env', () => { + expect(resolveDefaultAssistantUrl( + { OP_ASSISTANT_PORT: '4900' } as NodeJS.ProcessEnv, + { OP_ASSISTANT_PORT: '4800' } + )).toBe('http://127.0.0.1:4900'); + }); +}); + +// ── spawn env/args ──────────────────────────────────────────────────────────── + +describe('startClientServer spawn env/args', () => { + const savedRemote = { value: undefined as string | undefined }; + const savedClientPort = { value: undefined as string | undefined }; + const savedHostClientPort = { value: undefined as string | undefined }; + const savedAssistantPort = { value: undefined as string | undefined }; + const savedDefaultAssistantUrl = { value: undefined as string | undefined }; + + beforeEach(() => { + savedRemote.value = process.env.OP_ALLOW_REMOTE_SETUP; + savedClientPort.value = process.env.OP_CLIENT_PORT; + savedHostClientPort.value = process.env.OP_HOST_CLIENT_PORT; + savedAssistantPort.value = process.env.OP_ASSISTANT_PORT; + savedDefaultAssistantUrl.value = process.env.OP_CLIENT_DEFAULT_ASSISTANT_URL; + delete process.env.OP_ALLOW_REMOTE_SETUP; + delete process.env.OP_CLIENT_PORT; + delete process.env.OP_HOST_CLIENT_PORT; + delete process.env.OP_ASSISTANT_PORT; + delete process.env.OP_CLIENT_DEFAULT_ASSISTANT_URL; + }); + + afterEach(() => { + if (savedRemote.value === undefined) delete process.env.OP_ALLOW_REMOTE_SETUP; + else process.env.OP_ALLOW_REMOTE_SETUP = savedRemote.value; + if (savedClientPort.value === undefined) delete process.env.OP_CLIENT_PORT; + else process.env.OP_CLIENT_PORT = savedClientPort.value; + if (savedHostClientPort.value === undefined) delete process.env.OP_HOST_CLIENT_PORT; + else process.env.OP_HOST_CLIENT_PORT = savedHostClientPort.value; + if (savedAssistantPort.value === undefined) delete process.env.OP_ASSISTANT_PORT; + else process.env.OP_ASSISTANT_PORT = savedAssistantPort.value; + if (savedDefaultAssistantUrl.value === undefined) delete process.env.OP_CLIENT_DEFAULT_ASSISTANT_URL; + else process.env.OP_CLIENT_DEFAULT_ASSISTANT_URL = savedDefaultAssistantUrl.value; + }); + + it('spawns the serve script with PORT=3890 (default), HOST=127.0.0.1, and OP_CLIENT_DIR=', async () => { + const { handlePromise, spawns, buildDir, runtimeConfigWrites } = harness({}); + const handle = await handlePromise; + expect(handle).not.toBeNull(); + expect(spawns.length).toBe(1); + const { cmd, env } = spawns[0] as SpawnRecord; + // serve.mjs reads its config from the environment (PORT/HOST/OP_CLIENT_DIR). + expect(env.PORT).toBe('3890'); + expect(env.HOST).toBe('127.0.0.1'); + expect(env.OP_CLIENT_DIR).toBe(buildDir); + expect(env.OP_CLIENT_RUNTIME_CONFIG).toBe('/op-home/data/client/runtime-config.json'); + expect(runtimeConfigWrites).toEqual([ + { path: '/op-home/data/client/runtime-config.json', assistantUrl: 'http://127.0.0.1:3800' }, + ]); + // The child IS the serve script from the resolved client build. + expect(cmd).toContain(resolveClientServeScript(buildDir)); + await handle?.stop(); + }); + + it('honors a port override', async () => { + const { handlePromise, spawns } = harness({ port: 4001 }); + const handle = await handlePromise; + expect((spawns[0] as SpawnRecord).env.PORT).toBe('4001'); + await handle?.stop(); + }); + + it('seeds runtime-config.json with the assistant URL override when configured', async () => { + process.env.OP_CLIENT_DEFAULT_ASSISTANT_URL = 'https://assistant.example/oc'; + const { handlePromise, runtimeConfigWrites } = harness({}); + const handle = await handlePromise; + expect(runtimeConfigWrites).toEqual([ + { path: '/op-home/data/client/runtime-config.json', assistantUrl: 'https://assistant.example/oc' }, + ]); + await handle?.stop(); + }); + + it('uses OP_HOST_CLIENT_PORT when no explicit port is passed', async () => { + process.env.OP_HOST_CLIENT_PORT = '4011'; + const { handlePromise, spawns } = harness({}); + const handle = await handlePromise; + expect((spawns[0] as SpawnRecord).env.PORT).toBe('4011'); + await handle?.stop(); + }); + + it('does not let OP_CLIENT_PORT override the stable host localhost app port', async () => { + process.env.OP_CLIENT_PORT = '4810'; + const { handlePromise, spawns } = harness({}); + const handle = await handlePromise; + expect((spawns[0] as SpawnRecord).env.PORT).toBe('3890'); + await handle?.stop(); + }); + + it('stays loopback-only even when OP_ALLOW_REMOTE_SETUP is set (no remote escape hatch)', async () => { + // The UI server's remote-setup relaxation must never leak into the client + // static server: acceptance is "loopback-only enforced for the client + // server" — HOST stays pinned to 127.0.0.1 unconditionally. + process.env.OP_ALLOW_REMOTE_SETUP = '1'; + const { handlePromise, spawns } = harness({}); + const handle = await handlePromise; + expect((spawns[0] as SpawnRecord).env.HOST).toBe('127.0.0.1'); + await handle?.stop(); + }); +}); + +// ── skip-when-absent ────────────────────────────────────────────────────────── + +describe('startClientServer skip-when-absent', () => { + it('returns null, spawns nothing, and logs a skip when the client build is absent', async () => { + const { handlePromise, spawns, logs } = harness({ present: false }); + // Non-fatal by contract: the promise must RESOLVE (never reject/exit) so + // the UI supervisor keeps serving without the client. + const handle = await handlePromise; + expect(handle).toBeNull(); + expect(spawns.length).toBe(0); + const logged = logs.join('\n'); + expect(logged).toMatch(/client/i); + expect(logged).toMatch(/skip/i); + }); +}); + +// ── supervision ─────────────────────────────────────────────────────────────── + +describe('startClientServer supervision', () => { + it('respawns the client child after an unexpected exit', async () => { + const first = fakeClientProc(); + const second = fakeClientProc(); + const { handlePromise, spawns } = harness({ procs: [first, second] }); + const handle = await handlePromise; + expect(spawns.length).toBe(1); + + first.die(1); // the child crashes + await tick(); + + expect(spawns.length).toBe(2); // supervised: a fresh child was spawned + await handle?.stop(); + }); + + it('stop() SIGTERMs the child and suppresses any further respawn', async () => { + const first = fakeClientProc(); + const { handlePromise, spawns, procs } = harness({ procs: [first] }); + const handle = await handlePromise; + expect(handle).not.toBeNull(); + + await handle?.stop(); + await tick(); + + expect((procs[0] as FakeClientProc).signals).toContain('SIGTERM'); + expect(spawns.length).toBe(1); // an intentional stop must NOT respawn + }); +}); diff --git a/packages/cli/src/lib/client-server.ts b/packages/cli/src/lib/client-server.ts new file mode 100644 index 000000000..fccbb706a --- /dev/null +++ b/packages/cli/src/lib/client-server.ts @@ -0,0 +1,172 @@ +/** + * Client app static server — spawns and supervises the @openpalm/client + * zero-dependency serve script (bin/serve.mjs) from the RESOLVED client build + * on a stable loopback port (plan ui-runtime-modes-plan.md Phase 5 item 3, + * #555). Runs beside the UI child under the same `openpalm` / `openpalm admin` + * supervisor process. + * + * Policy pins (see client-server.test.ts — the tests are the contract): + * - Loopback-only ALWAYS: HOST is 127.0.0.1 unconditionally. The UI server's + * OP_ALLOW_REMOTE_SETUP relaxation NEVER applies here — remote access to + * the client app is the assistant container's job (#510), not this + * process's. + * - Non-fatal when absent: no client build on disk → log + skip (return + * null). The UI must keep serving. + * - Supervised: an unexpected child exit respawns it; stop() SIGTERMs and + * suppresses any further respawn. + */ +import { join, basename } from 'node:path'; +import { existsSync as nodeExistsSync } from 'node:fs'; +import { + parseEnvFile, + resolveClientAppPort, + resolveClientBuildDir, + resolveDataDir, + resolveOpenPalmHome, + writeClientRuntimeConfig, +} from '@openpalm/lib'; + +const STOP_TIMEOUT_MS = 5_000; +const RESPAWN_DELAY_MS = 1_000; + +/** Minimal child-process surface the supervisor drives (injectable for tests). */ +export type ClientChildProc = Pick; + +/** + * Resolve the serve script that travels WITH the resolved client build: + * `/bin/serve.mjs`, a sibling of the build dir. Holds for both + * channels — OP_HOME/data/client/{build,bin} and + * $OPENPALM_REPO_ROOT/packages/client/{build,bin}. + */ +export function resolveClientServeScript(buildDir: string): string { + return join(buildDir, '..', 'bin', 'serve.mjs'); +} + +export function resolveHostClientRuntimeConfigPath(dataDir = resolveDataDir()): string { + return join(dataDir, 'client', 'runtime-config.json'); +} + +function readPersistedStackEnv(): Record { + try { + return parseEnvFile(join(resolveOpenPalmHome(), 'knowledge', 'env', 'stack.env')); + } catch { + return {}; + } +} + +export function resolveDefaultAssistantUrl( + env: NodeJS.ProcessEnv = process.env, + persistedEnv: Record = readPersistedStackEnv(), +): string { + const merged = { ...persistedEnv, ...env }; + return merged.OP_CLIENT_DEFAULT_ASSISTANT_URL || `http://127.0.0.1:${merged.OP_ASSISTANT_PORT || '3800'}`; +} + +/** Running client-server handle: stop() kills the child and ends supervision. */ +export interface ClientServerHandle { + stop: () => Promise; +} + +/** Injectable dependencies for {@link startClientServer} (real process/fs by default). */ +export interface ClientServerDeps { + /** Port to serve on (default: OP_HOST_CLIENT_PORT env or DEFAULT_CLIENT_PORT). */ + port?: number; + /** Resolve the client build dir (defaults to the shared lib resolver). */ + resolveBuildDir?: () => string; + /** Existence probe for the build gate + serve script (defaults to node:fs). */ + existsSync?: (path: string) => boolean; + /** Spawn the serve child (defaults to Bun.spawn with inherited stdio). */ + spawnFn?: (cmd: string[], opts: { env: Record }) => ClientChildProc; + resolveRuntimeConfigPath?: () => string; + resolveAssistantUrl?: () => string; + writeRuntimeConfig?: (path: string, assistantUrl: string) => void; + log?: (...args: unknown[]) => void; + logError?: (...args: unknown[]) => void; + /** Sleep for the respawn delay and the stop grace window. */ + sleep?: (ms: number) => Promise; + /** Force-kill grace window, ms (defaults to STOP_TIMEOUT_MS). */ + stopTimeoutMs?: number; +} + +/** + * Start (and supervise) the client static server. Resolves to a handle, or to + * null when no client build is present — NON-FATAL by contract: this function + * never throws and never exits; the UI supervisor keeps serving without the + * client app. + */ +export async function startClientServer(deps: ClientServerDeps = {}): Promise { + const persistedEnv = readPersistedStackEnv(); + const port = deps.port ?? resolveClientAppPort({ ...persistedEnv, ...process.env }); + const resolveBuildDir = deps.resolveBuildDir ?? resolveClientBuildDir; + const exists = deps.existsSync ?? nodeExistsSync; + const spawnFn = deps.spawnFn + ?? ((cmd: string[], opts: { env: Record }): ClientChildProc => + Bun.spawn(cmd, { env: opts.env, stdout: 'inherit', stderr: 'inherit' })); + const log = deps.log ?? console.log; + const logError = deps.logError ?? console.error; + const sleep = deps.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + const stopTimeoutMs = deps.stopTimeoutMs ?? STOP_TIMEOUT_MS; + const resolveRuntimeConfigPath = deps.resolveRuntimeConfigPath ?? (() => resolveHostClientRuntimeConfigPath()); + const resolveAssistantUrl = deps.resolveAssistantUrl ?? (() => resolveDefaultAssistantUrl(process.env)); + const writeRuntimeConfig = deps.writeRuntimeConfig ?? writeClientRuntimeConfig; + + const buildDir = resolveBuildDir(); + const serveScript = resolveClientServeScript(buildDir); + if (!exists(join(buildDir, 'index.html')) || !exists(serveScript)) { + log(`Client app build not found at ${buildDir} — skipping the client server (the UI keeps serving).`); + return null; + } + + const runtimeConfigPath = resolveRuntimeConfigPath(); + try { + writeRuntimeConfig(runtimeConfigPath, resolveAssistantUrl()); + } catch (err) { + logError(`Failed to write client runtime config at ${runtimeConfigPath}: ${err instanceof Error ? err.message : String(err)}`); + } + + // The child runs on THIS binary's embedded runtime (no system `node` + // required — same rationale as the UI child in ui-server.ts): + // dev (bun) → [bun, ] bun executes the script + // compiled binary → [binary, 'client-serve'] re-invoke `openpalm + // client-serve`, which imports the resolved serve script + // in-process (OP_CLIENT_DIR below pins the served dir). + const execName = basename(process.execPath).toLowerCase(); + const runningAsBun = execName === 'bun' || execName === 'bun.exe'; + const cmd = runningAsBun ? [process.execPath, serveScript] : [process.execPath, 'client-serve']; + // serve.mjs reads its config from the environment (PORT / HOST / + // OP_CLIENT_DIR). HOST is pinned to loopback AFTER the process.env spread so + // nothing — including OP_ALLOW_REMOTE_SETUP — can loosen the bind. + const env: Record = { + ...process.env, + PORT: String(port), + HOST: '127.0.0.1', + OP_CLIENT_DIR: buildDir, + OP_CLIENT_RUNTIME_CONFIG: runtimeConfigPath, + }; + + let stopping = false; + let proc = spawnFn(cmd, { env }); + log(`Client app served at http://127.0.0.1:${port} (from ${buildDir})`); + + // Supervision loop: respawn on any unexpected exit; an intentional stop() + // flips `stopping` first, which both breaks the loop and suppresses respawn. + void (async () => { + while (!stopping) { + const code = await proc.exited; + if (stopping) break; + logError(`Client app server exited unexpectedly (code ${code}) — restarting.`); + await sleep(RESPAWN_DELAY_MS); + if (stopping) break; + proc = spawnFn(cmd, { env }); + } + })(); + + return { + stop: async () => { + stopping = true; + proc.kill('SIGTERM'); + await Promise.race([proc.exited, sleep(stopTimeoutMs)]); + if (!proc.killed) proc.kill('SIGKILL'); + }, + }; +} diff --git a/packages/cli/src/lib/ports.ts b/packages/cli/src/lib/ports.ts index fda7c5462..b9939d8ac 100644 --- a/packages/cli/src/lib/ports.ts +++ b/packages/cli/src/lib/ports.ts @@ -9,3 +9,5 @@ export const DEFAULT_UI_PORT = 3880; /** Default published host port for the assistant (override via OP_ASSISTANT_PORT). */ export const DEFAULT_ASSISTANT_PORT = 3800; + +export { DEFAULT_CLIENT_PORT } from '@openpalm/lib'; diff --git a/packages/cli/src/lib/ui-server.test.ts b/packages/cli/src/lib/ui-server.test.ts index 78e7fe509..5e684ce1d 100644 --- a/packages/cli/src/lib/ui-server.test.ts +++ b/packages/cli/src/lib/ui-server.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test'; -import { createCliUiSupervisor, type CliChildProc } from './ui-server.ts'; +import { createCliUiSupervisor, type CliChildProc, waitForClientApp } from './ui-server.ts'; // Behavioral coverage for the CLI's thin UiSupervisor adapter, driven through // injected fakes (no real processes). Locks the exit-based failure policy @@ -94,3 +94,34 @@ describe('createCliUiSupervisor exit policy', () => { expect(exits).toEqual([]); }); }); + +describe('waitForClientApp', () => { + it('returns true once the localhost client app becomes reachable', async () => { + let attempts = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + attempts += 1; + if (attempts < 2) throw new Error('not ready'); + return new Response('', { status: 200 }); + }) as typeof fetch; + + try { + expect(await waitForClientApp('http://127.0.0.1:3890/chat', 1500)).toBe(true); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it('returns false when the localhost client app never becomes reachable', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => { + throw new Error('still down'); + }) as typeof fetch; + + try { + expect(await waitForClientApp('http://127.0.0.1:3890/chat', 250)).toBe(false); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/packages/cli/src/lib/ui-server.ts b/packages/cli/src/lib/ui-server.ts index 22e71b4d5..cf741b516 100644 --- a/packages/cli/src/lib/ui-server.ts +++ b/packages/cli/src/lib/ui-server.ts @@ -10,20 +10,48 @@ import { join, basename } from 'node:path'; import { existsSync } from 'node:fs'; import { resolveOpenPalmHome, resolveUiBuildDir, createLogger, readSecret, - checkAndUpdateUiBuild, checkAndUpdateSkeleton, PLATFORM_VERSION, - isRemoteSetupAllowed, waitForReady, restoreUiBackup, UiSupervisor, + checkAndUpdateClientBuild, checkAndUpdateUiBuild, checkAndUpdateSkeleton, PLATFORM_VERSION, + isRemoteSetupAllowed, resolveClientAppUrl, restoreUiBackup, UiSupervisor, waitForReady, } 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'; +import { startClientServer } from './client-server.ts'; const logger = createLogger('cli:ui'); const DEFAULT_PORT = Number(process.env.OP_HOST_UI_PORT) || DEFAULT_UI_PORT; const STOP_TIMEOUT_MS = 5_000; +const CLIENT_READY_TIMEOUT_MS = 5_000; +const CLIENT_READY_POLL_MS = 200; export interface UIServerOptions { port?: number; open?: boolean; + openTarget?: 'ui' | 'client'; + /** + * 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; +} + +export async function waitForClientApp(url: string, timeoutMs = CLIENT_READY_TIMEOUT_MS): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(1_000), + }); + if (response.ok) return true; + } catch { + // Not ready yet. + } + await new Promise((resolve) => setTimeout(resolve, CLIENT_READY_POLL_MS)); + } + return false; } /** @@ -41,6 +69,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 +121,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 +146,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,19 +296,47 @@ 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 clientUrl = resolveClientAppUrl(process.env); 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), }); if (!await supervisor.start()) return; // onStartFailure already exited console.log(`UI server running at ${uiUrl}`); - if (opts.open !== false) await openBrowser(uiUrl); + + // Serve the @openpalm/client static app beside the UI on its stable loopback + // port (P5c, #555; plan Phase 5 item 3). Both the default serve path and + // `openpalm admin` come through here. Non-fatal: absent build → log + skip + // (null handle), and the UI keeps serving. + console.log('Checking for client app update...'); + const clientResult = await checkAndUpdateClientBuild(PLATFORM_VERSION, state.dataDir); + if (clientResult.updated) { + console.log(`Client app updated to v${clientResult.latestVersion}.`); + } else if (clientResult.error) { + console.warn(`Warning: client app update skipped — ${clientResult.error}. Existing build still active.`); + } + const clientHandle = await startClientServer(); + + if (opts.open !== false) { + if (opts.openTarget === 'client') { + if (!clientHandle || !await waitForClientApp(clientUrl)) { + console.error(`Localhost client app is not reachable at ${clientUrl}`); + process.exit(1); + } + await openBrowser(clientUrl); + } else { + await openBrowser(uiUrl); + } + } // Supervisor restart (§4.4): the UI child (admin "install UI version" route) // sends SIGUSR2/SIGHUP to this parent after seeding a newer data/ui. The @@ -282,6 +349,7 @@ export async function startUIServer(opts: UIServerOptions = {}): Promise { supervisor.markShuttingDown(); console.log(`\nReceived ${signal}. Shutting down...`); try { + if (clientHandle) await clientHandle.stop(); const proc = supervisor.current; if (proc) await stopUiProc(proc); console.log('Shutdown complete.'); diff --git a/packages/cli/src/main.test.ts b/packages/cli/src/main.test.ts index e3be8c1d7..f345a87ca 100644 --- a/packages/cli/src/main.test.ts +++ b/packages/cli/src/main.test.ts @@ -14,12 +14,15 @@ function writeMinimalSetupSpec(dir: string): string { const specPath = join(dir, 'setup-spec.yaml'); const yaml = [ 'version: 2', - 'capabilities:', - ' llm: openai/gpt-4o', - ' embeddings:', - ' provider: openai', - ' model: text-embedding-3-small', - ' dims: 1536', + 'llm:', + ' provider: openai', + ' model: gpt-4o', + ' baseUrl: https://api.openai.com/v1', + 'embedding:', + ' provider: openai', + ' model: text-embedding-3-small', + ' dims: 1536', + ' baseUrl: https://api.openai.com/v1', 'security:', ' uiLoginPassword: test-admin-token-12345', 'owner:', @@ -342,6 +345,99 @@ describe('cli main', () => { } }); + it('persists install-time project and port overrides into stack.env for later start', async () => { + const base = mkdtempSync(join(tmpdir(), 'openpalm-install-overrides-')); + const workDir = join(base, 'work'); + const specFile = writeMinimalSetupSpec(base); + const originalProject = process.env.OP_PROJECT_NAME; + const originalAssistantPort = process.env.OP_ASSISTANT_PORT; + const originalHostUiPort = process.env.OP_HOST_UI_PORT; + const originalHostClientPort = process.env.OP_HOST_CLIENT_PORT; + const originalClientPort = process.env.OP_CLIENT_PORT; + + process.env.OP_HOME = base; + process.env.OP_WORK_DIR = workDir; + process.env.OP_PROJECT_NAME = 'openpalm-test-install'; + process.env.OP_ASSISTANT_PORT = '4802'; + process.env.OP_HOST_UI_PORT = '9300'; + process.env.OP_HOST_CLIENT_PORT = '9390'; + process.env.OP_CLIENT_PORT = '3842'; + + mockDockerCli(); + globalThis.fetch = mock(async (input: string | URL) => { + const url = String(input); + if (url.endsWith('/health')) return new Response('ok', { status: 200 }); + if (url.includes('/core.compose.yml') || url.includes('/compose.yml')) return new Response('services: {}\n', { status: 200 }); + if (url.includes('/AGENTS.md')) return new Response('# Agents\n', { status: 200 }); + if (url.includes('/opencode.jsonc')) return new Response('{"$schema":"https://opencode.ai/config.json"}\n', { status: 200 }); + if (url.endsWith('.yml')) return new Response('name: test\nschedule: daily\n', { status: 200 }); + return new Response('', { status: 503 }); + }) as unknown as typeof fetch; + console.log = mock(() => {}) as typeof console.log; + console.warn = mock(() => {}) as typeof console.warn; + + try { + await main(['install', '--no-start', '--file', specFile]); + const stackEnv = readFileSync(join(base, 'knowledge', 'env', 'stack.env'), 'utf-8'); + expect(stackEnv).toContain('OP_PROJECT_NAME=openpalm-test-install'); + expect(stackEnv).toContain('OP_ASSISTANT_PORT=4802'); + expect(stackEnv).toContain('OP_HOST_UI_PORT=9300'); + expect(stackEnv).toContain('OP_HOST_CLIENT_PORT=9390'); + expect(stackEnv).toContain('OP_CLIENT_PORT=3842'); + } finally { + if (originalProject === undefined) delete process.env.OP_PROJECT_NAME; + else process.env.OP_PROJECT_NAME = originalProject; + if (originalAssistantPort === undefined) delete process.env.OP_ASSISTANT_PORT; + else process.env.OP_ASSISTANT_PORT = originalAssistantPort; + if (originalHostUiPort === undefined) delete process.env.OP_HOST_UI_PORT; + else process.env.OP_HOST_UI_PORT = originalHostUiPort; + if (originalHostClientPort === undefined) delete process.env.OP_HOST_CLIENT_PORT; + else process.env.OP_HOST_CLIENT_PORT = originalHostClientPort; + if (originalClientPort === undefined) delete process.env.OP_CLIENT_PORT; + else process.env.OP_CLIENT_PORT = originalClientPort; + rmSync(base, { recursive: true, force: true }); + } + }); + + it('rejects the legacy capabilities wrapper for install --file', async () => { + const base = mkdtempSync(join(tmpdir(), 'openpalm-install-legacy-')); + const workDir = join(base, 'work'); + const specFile = join(base, 'setup-spec-legacy.yaml'); + const mainPath = join(fileURLToPath(new URL('./', import.meta.url)), 'main.ts'); + const yaml = [ + 'version: 2', + 'capabilities:', + ' llm: openai/gpt-4o', + ' embeddings:', + ' provider: openai', + ' model: text-embedding-3-small', + ' dims: 1536', + 'security:', + ' uiLoginPassword: test-admin-token-12345', + 'connections: []', + '', + ].join('\n'); + writeFileSync(specFile, yaml); + + process.env.OP_HOME = base; + process.env.OP_WORK_DIR = workDir; + + try { + const proc = Bun.spawn(['bun', mainPath, 'install', '--no-start', '--file', specFile], { + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, OP_HOME: base, OP_WORK_DIR: workDir }, + }); + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const code = await proc.exited; + expect(code).not.toBe(0); + expect(stdout + stderr).toContain('modern flat shape'); + } finally { + rmSync(base, { recursive: true, force: true }); + } + }); + it('supports addon enable/disable commands', async () => { const base = mkdtempSync(join(tmpdir(), 'openpalm-addon-cli-')); const coreCompose = join(base, 'system', 'stack', 'core.compose.yml'); @@ -673,6 +769,14 @@ describe('UI host server', () => { const cmd = (await sub()) as { meta?: { name?: string } }; expect(cmd.meta?.name).toBe("ui"); }); + + it("the `app` subcommand is registered", async () => { + const { mainCommand } = await import("./main.ts"); + const sub = (mainCommand.subCommands as Record Promise>).app; + expect(typeof sub).toBe("function"); + const cmd = (await sub()) as { meta?: { name?: string } }; + expect(cmd.meta?.name).toBe("app"); + }); }); describe('secrets.env generation', () => { diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 401b703fe..e37fa0756 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,8 @@ 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), + app: () => import('./commands/app.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), @@ -102,6 +104,7 @@ const subCommands = { automations: () => import('./commands/automations.ts').then((m) => m.default), unlock: () => import('./commands/unlock.ts').then((m) => m.default), ui: () => import('./commands/ui.ts').then((m) => m.default), + 'client-serve': () => import('./commands/client-serve.ts').then((m) => m.default), }; export const mainCommand = defineCommand({ diff --git a/packages/client/bin/serve.mjs b/packages/client/bin/serve.mjs new file mode 100644 index 000000000..879a2bb3a --- /dev/null +++ b/packages/client/bin/serve.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +/** + * Zero-dependency static file server for the @openpalm/client build (P5b + * item 4, #555; plan §6.9 Slice A / §6.10). Serves the adapter-static + * bundle with an SPA fallback to index.html, plus a runtime-config.json if + * one exists beside the build (the assistant container writes it in P5d). + * + * No API routes, no @openpalm/lib, no auth — this process serves bytes. + * Binds loopback by default; pass --host only where the surrounding policy + * already gates exposure (e.g. inside the assistant container, #510). + * + * Usage: serve.mjs [--port N] [--host ADDR] [--dir PATH] + * Env: PORT, HOST, OP_CLIENT_DIR + */ +import { createReadStream, existsSync, statSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { extname, join, normalize, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +function arg(name) { + const index = process.argv.indexOf(`--${name}`); + return index !== -1 ? process.argv[index + 1] : undefined; +} + +const port = Number(arg('port') ?? process.env.PORT ?? 4180); +const host = arg('host') ?? process.env.HOST ?? '127.0.0.1'; +const dir = resolve( + arg('dir') ?? process.env.OP_CLIENT_DIR ?? join(fileURLToPath(new URL('..', import.meta.url)), 'build') +); +const runtimeConfigPath = process.env.OP_CLIENT_RUNTIME_CONFIG + ? resolve(process.env.OP_CLIENT_RUNTIME_CONFIG) + : ''; + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', + '.webmanifest': 'application/manifest+json', + '.woff2': 'font/woff2', + '.txt': 'text/plain; charset=utf-8', +}; + +function send(res, path, status = 200, extraHeaders = {}, includeBody = true) { + res.writeHead(status, { + 'content-type': MIME[extname(path)] ?? 'application/octet-stream', + ...extraHeaders, + }); + if (!includeBody) return res.end(); + createReadStream(path).pipe(res); +} + +function runtimeConfigHeaders() { + return { 'cache-control': 'no-store' }; +} + +const server = createServer((req, res) => { + const method = req.method ?? 'GET'; + const supportsBody = method !== 'HEAD'; + + let pathname; + try { + pathname = decodeURIComponent(new URL(req.url ?? '/', 'http://x').pathname); + } catch { + // Malformed percent-escapes (e.g. /%zz) throw; a bad request must never + // take down the co-process the harness and assistant container rely on. + res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' }); + return res.end('bad request'); + } + // normalize() collapses any ../ segments; the join stays inside dir. + const candidate = join(dir, normalize(join('/', pathname))); + if (existsSync(candidate) && statSync(candidate).isFile()) { + if (pathname === '/runtime-config.json') { + return send(res, candidate, 200, runtimeConfigHeaders(), supportsBody); + } + return send(res, candidate, 200, {}, supportsBody); + } + // runtime-config.json may live beside the build dir instead of inside it + // (the assistant container writes it next to the extracted bundle, P5d). + if (pathname === '/runtime-config.json') { + const candidates = [runtimeConfigPath, join(dir, '..', 'runtime-config.json')].filter(Boolean); + for (const path of candidates) { + if (existsSync(path) && statSync(path).isFile()) { + return send(res, path, 200, runtimeConfigHeaders(), supportsBody); + } + } + res.writeHead(404, { + 'content-type': 'text/plain; charset=utf-8', + ...runtimeConfigHeaders(), + }); + return res.end('no runtime-config.json'); + } + // SPA fallback: every route is client-rendered from index.html. + const fallback = join(dir, 'index.html'); + if (existsSync(fallback)) return send(res, fallback, 200, {}, supportsBody); + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); + res.end('client build not found — run `bun run client:build`'); +}); + +server.listen(port, host, () => { + console.log(`[openpalm-client] serving ${dir} on http://${host}:${port}`); +}); diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 000000000..b528bd097 --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,43 @@ +{ + "name": "@openpalm/client", + "version": "0.12.52", + "description": "OpenPalm client app — the unprivileged chat/connections static SPA (plan ui-runtime-modes-plan.md §6.11). Talks directly to per-connection OpenCode/guardian base URLs; connections persist in IndexedDB. §8.10 rule: this package NEVER depends on @openpalm/lib and never holds host credentials; runtime dependencies stay empty (the adapter-static build is fully self-contained, and bin/serve.mjs is zero-dependency) — document any exception here.", + "license": "MPL-2.0", + "type": "module", + "repository": { + "type": "git", + "url": "https://github.com/itlackey/openpalm.git", + "directory": "packages/client" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "build", + "bin" + ], + "bin": { + "openpalm-client-serve": "bin/serve.mjs" + }, + "scripts": { + "dev": "PORT=${PORT:-5174} vite dev", + "build": "svelte-kit sync && vite build && node scripts/stamp-version.mjs", + "clean:build": "rm -rf .svelte-kit build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings", + "test": "bun test", + "serve": "node bin/serve.mjs" + }, + "devDependencies": { + "@openpalm/ui-kit": "workspace:*", + "@sveltejs/adapter-static": "^3.0.10", + "@vite-pwa/sveltekit": "^1.1.0", + "@types/node": "^25.9.1", + "@sveltejs/kit": "^2.53.3", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "svelte": "^5.53.5", + "svelte-check": "^4.1.1", + "typescript": "^6.0.3", + "vite": "^8.0.14" + } +} diff --git a/packages/client/scripts/stamp-version.mjs b/packages/client/scripts/stamp-version.mjs new file mode 100644 index 000000000..43fb1d165 --- /dev/null +++ b/packages/client/scripts/stamp-version.mjs @@ -0,0 +1,18 @@ +// Writes the package version into the client build root as +// `.openpalm-client-version` — same pattern as packages/ui's stamp +// (`.openpalm-ui-version`): the stamp travels with the static bundle +// wherever it is copied/extracted so hosts can compare delivery channels +// by version (plan §3 exact-pin delivery; P5b, #555). +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); +const buildDir = join(pkgRoot, 'build'); +if (!existsSync(buildDir)) { + console.error(`[stamp-version] build dir not found at ${buildDir} — run the build first`); + process.exit(1); +} +const { version } = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')); +writeFileSync(join(buildDir, '.openpalm-client-version'), `${version}\n`); +console.log(`[stamp-version] stamped client build as ${version}`); diff --git a/packages/client/src/app.css b/packages/client/src/app.css new file mode 100644 index 000000000..b982b0054 --- /dev/null +++ b/packages/client/src/app.css @@ -0,0 +1,142 @@ +@import url('https://fonts.googleapis.com/css2?family=Poor+Story&family=Iosevka+Charon+Mono:ital,wght@0,300;0,400;0,500;0,700;1,300;1,400;1,500;1,700&display=swap'); +/* Stillness design-system tokens live in the shared ui-kit (plan §6.11). + Vite resolves the package specifier and inlines the file at build time. + Must stay above all style rules — CSS ignores @import after the first + rule. The reset/base/button rules below are the client's subset of + packages/ui/src/app.css (host-app-only chrome omitted). */ +@import '@openpalm/ui-kit/theme/tokens.css'; + +/* Brushed icon filter — applied to all .s-icon SVGs. The #ibrush filter is + defined once in the root layout. */ +.s-icon { filter: url(#ibrush); } + +/* ── Reset & Base ──────────────────────────────────────────────────── */ + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + font-size: 16px; + color-scheme: light; +} + +html[data-theme='dark'] { + color-scheme: dark; +} + +body { + font-family: var(--s-font-display); + font-size: var(--s-type-whisper); + line-height: 1.6; + color: var(--s-ink); + background: var(--s-paper); + min-height: 100vh; +} + +h1, h2, h3, h4 { + font-family: var(--s-font-header); +} + +/* ── Scrollbar ─────────────────────────────────────────────────────── */ + +* { + scrollbar-width: thin; + scrollbar-color: var(--s-line) transparent; +} + +::-webkit-scrollbar { + width: 4px; + height: 4px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--s-line); + border-radius: 0; +} + +/* ── Focus & Accessibility ─────────────────────────────────────────── */ + +:focus-visible { + outline: 2px solid var(--s-seal); + outline-offset: 2px; +} + +/* ── Shared Utility: Buttons ───────────────────────────────────────── */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.5rem 0.9rem; + min-height: 2rem; + font-family: var(--s-font-mono); + font-size: var(--s-type-mark); + letter-spacing: var(--s-track-label); + text-transform: uppercase; + font-weight: 400; + border: var(--s-hair) solid var(--s-line); + border-radius: 2px; + background: none; + color: var(--s-ink-3); + cursor: pointer; + transition: color 0.2s ease, border-color 0.2s ease; + white-space: nowrap; +} + +.btn:disabled { + opacity: 0.38; + cursor: not-allowed; +} + +.btn-primary { + border-color: var(--s-seal); + color: var(--s-seal); + background: none; +} + +.btn-primary:hover:not(:disabled) { + background: color-mix(in srgb, var(--s-seal) 8%, transparent); + border-color: var(--s-seal); +} + +.btn-secondary { + background: none; + color: var(--s-ink-3); + border-color: var(--s-line); +} + +.btn-secondary:hover:not(:disabled) { + color: var(--s-ink-2); + border-color: var(--s-line); +} + +.btn-danger { + border-color: color-mix(in srgb, var(--s-error) 50%, transparent); + color: var(--s-error); + background: none; + opacity: 0.8; +} + +.btn-danger:hover:not(:disabled) { + opacity: 1; + border-color: var(--s-error); +} + +.btn-sm { + padding: 0.3rem 0.7rem; + min-height: 1.6rem; + font-size: 0.58rem; +} diff --git a/packages/client/src/app.d.ts b/packages/client/src/app.d.ts new file mode 100644 index 000000000..520c4217a --- /dev/null +++ b/packages/client/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/packages/client/src/app.html b/packages/client/src/app.html new file mode 100644 index 000000000..20769af22 --- /dev/null +++ b/packages/client/src/app.html @@ -0,0 +1,52 @@ + + + + + + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/packages/client/src/lib/boot.ts b/packages/client/src/lib/boot.ts new file mode 100644 index 000000000..ec6829900 --- /dev/null +++ b/packages/client/src/lib/boot.ts @@ -0,0 +1,62 @@ +/** + * Client boot (P5b, #555): one storage backend + one ConnectionStore + + * one SecretStore per browsing session, seeded once from the origin's + * runtime-config.json (plan §6.6 — the assistant container writes the file + * beside the static build in P5d; absent file = no default connection). + * + * Every route awaits getClientBoot(), so the seed runs exactly once no + * matter which page the SPA fallback lands on first. Offline boots resolve + * too: loadRuntimeConfig() -> null -> seed no-op -> stored connections + * remain readable (plan §6.10). + */ +import { + type ConnectionStorage, + type ConnectionStore, + createConnectionStore, + createIndexedDbStorage, + createMemoryStorage, + loadRuntimeConfig, +} from './connections/index.js'; +import { createSecretStore, type SecretStore } from './connections/secrets.js'; + +export type ClientBoot = { + store: ConnectionStore; + secrets: SecretStore; +}; + +let bootPromise: Promise | null = null; + +function pickStorage(): { storage: ConnectionStorage; persistent: boolean } { + // Some private-browsing modes refuse IndexedDB entirely; degrade to a + // session-only in-memory backend rather than a blank page. + try { + if (typeof indexedDB !== 'undefined') { + return { storage: createIndexedDbStorage(), persistent: true }; + } + } catch { + // fall through + } + return { storage: createMemoryStorage(), persistent: false }; +} + +async function bootWithStorage(storage: ConnectionStorage): Promise { + const store = createConnectionStore({ storage }); + await store.seedFromRuntimeConfig(await loadRuntimeConfig()); + return { store, secrets: createSecretStore(storage) }; +} + +export function getClientBoot(): Promise { + bootPromise ??= (async () => { + const selected = pickStorage(); + try { + return await bootWithStorage(selected.storage); + } catch (error) { + if (!selected.persistent) throw error; + return bootWithStorage(createMemoryStorage()); + } + })().catch((error) => { + bootPromise = null; + throw error; + }); + return bootPromise; +} diff --git a/packages/client/src/lib/components/chat/ChatInput.svelte b/packages/client/src/lib/components/chat/ChatInput.svelte new file mode 100644 index 000000000..406f36787 --- /dev/null +++ b/packages/client/src/lib/components/chat/ChatInput.svelte @@ -0,0 +1,181 @@ + + +
{ e.preventDefault(); submit(); }} +> + + +
+ + diff --git a/packages/client/src/lib/components/chat/ChatTurn.svelte b/packages/client/src/lib/components/chat/ChatTurn.svelte new file mode 100644 index 000000000..dd414381e --- /dev/null +++ b/packages/client/src/lib/components/chat/ChatTurn.svelte @@ -0,0 +1,123 @@ + + +{#if role === 'user'} +
+
{text}
+
You
+
+{:else} +
+
+

{text}

+
+
Assistant
+
+{/if} + + diff --git a/packages/client/src/lib/connections/index.ts b/packages/client/src/lib/connections/index.ts new file mode 100644 index 000000000..c45548d5b --- /dev/null +++ b/packages/client/src/lib/connections/index.ts @@ -0,0 +1,333 @@ +/** + * Client-side connection store (plan ui-runtime-modes-plan.md §6.6; P5b + * item 2, #555). + * + * ConnectionEntry records persist behind a storage abstraction so the same + * store logic runs over IndexedDB in the browser (offline-readable, plan + * §6.10) and over an in-memory backend in tests. The store API is async + * throughout (IndexedDB is async) and keeps NO state in store-instance + * fields — a page reload constructs a new store over the same backend and + * must see identical state. packages/client/tests/connections-*.test.ts is + * the pinned contract for both backends. + * + * Locked/default entries are seeded from a runtime-config.json fetched from + * the app's own origin at boot (the assistant container writes the file + * beside the static build, P5d). Absent file = no default connection. + */ + +export type ConnectionKind = 'local-opencode' | 'remote-opencode' | 'openpalm-client-api'; + +/** + * Plan §6.6 ConnectionEntry, client-side. `grantedCapabilities` is a plain + * string list here — the client's type space has no host:* capabilities + * (plan §4.3 note, §8.5: host features are absent from the artifact), and + * grants must be server-verified at connection-add time (§8.9). + */ +export type ConnectionEntry = { + id: string; + label: string; + kind: ConnectionKind; + url: string; + /** Credentials live under `secretRef` in the secret store — never inline. */ + auth: { mode: 'none' | 'basic' | 'bearer'; secretRef?: string }; + isDefault?: boolean; + locked?: boolean; + grantedCapabilities?: string[]; +}; + +export type NewConnectionInput = Omit & { id?: string }; + +/** Shape of the runtime-config.json written beside the static build (P5d). */ +export type RuntimeConfig = { + connections: ConnectionEntry[]; +}; + +function isLoopbackHost(hostname: string): boolean { + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '[::1]'; +} + +function rewriteLoopbackUrlForBrowserHost(rawUrl: string): string { + const locationLike = globalThis.location; + if (!locationLike || isLoopbackHost(locationLike.hostname)) return rawUrl; + try { + const url = new URL(rawUrl); + if (!isLoopbackHost(url.hostname)) return rawUrl; + url.hostname = locationLike.hostname; + return url.toString(); + } catch { + return rawUrl; + } +} + +function adaptRuntimeConfigForBrowser(config: RuntimeConfig): RuntimeConfig { + return { + connections: config.connections.map((entry) => ({ + ...entry, + url: entry.locked ? rewriteLoopbackUrlForBrowserHost(entry.url) : entry.url, + })), + }; +} + +/** + * Storage backend contract: connection records by id plus a small string + * meta area (active selection, secret material). Implemented by + * createMemoryStorage() (tests) and createIndexedDbStorage() (browser) — + * the ConnectionStore is the only consumer. + */ +export type ConnectionStorage = { + getAll(): Promise; + get(id: string): Promise; + put(entry: ConnectionEntry): Promise; + delete(id: string): Promise; + getMeta(key: string): Promise; + /** null deletes the key. */ + setMeta(key: string, value: string | null): Promise; +}; + +export type ConnectionStore = { + list(): Promise; + get(id: string): Promise; + /** Generates an id when the input has none. */ + add(input: NewConnectionInput): Promise; + /** Rejects for unknown ids and for locked entries. */ + update(id: string, patch: Partial>): Promise; + /** Rejects for unknown ids and for locked entries. */ + remove(id: string): Promise; + getActiveId(): Promise; + getActive(): Promise; + /** Rejects for unknown ids. */ + setActive(id: string): Promise; + /** + * Upsert the config's (locked/default) entries by id. null config = no-op. + * A seeded isDefault entry becomes active when nothing is active yet, but + * never steals an explicit user selection. Config wins for locked entries + * (label/url updates apply on re-seed); user-added entries are untouched. + */ + seedFromRuntimeConfig(config: RuntimeConfig | null): Promise; +}; + +const ACTIVE_ID_KEY = 'activeId'; + +function clone(value: T): T { + return structuredClone(value); +} + +/** In-memory storage backend — same semantics as the IndexedDB one. */ +export function createMemoryStorage(): ConnectionStorage { + const entries = new Map(); + const meta = new Map(); + return { + async getAll() { + return [...entries.values()].map(clone); + }, + async get(id) { + const entry = entries.get(id); + return entry ? clone(entry) : null; + }, + async put(entry) { + entries.set(entry.id, clone(entry)); + }, + async delete(id) { + entries.delete(id); + }, + async getMeta(key) { + return meta.get(key) ?? null; + }, + async setMeta(key, value) { + if (value === null) meta.delete(key); + else meta.set(key, value); + }, + }; +} + +// ── IndexedDB backend (browser) ────────────────────────────────────────── + +const IDB_NAME = 'openpalm-client'; +const IDB_VERSION = 1; +const STORE_CONNECTIONS = 'connections'; +const STORE_META = 'meta'; + +function idbRequest(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed')); + }); +} + +function openDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(IDB_NAME, IDB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_CONNECTIONS)) { + db.createObjectStore(STORE_CONNECTIONS, { keyPath: 'id' }); + } + if (!db.objectStoreNames.contains(STORE_META)) { + db.createObjectStore(STORE_META); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error('IndexedDB open failed')); + }); +} + +/** + * IndexedDB storage backend (raw IDB API, no deps). Lazily opens the + * database on first use; each operation runs in its own short transaction. + */ +export function createIndexedDbStorage(): ConnectionStorage { + let db: Promise | null = null; + const database = (): Promise => { + db ??= openDatabase(); + return db; + }; + const store = async (name: string, mode: IDBTransactionMode): Promise => + (await database()).transaction(name, mode).objectStore(name); + + return { + async getAll() { + return idbRequest( + (await store(STORE_CONNECTIONS, 'readonly')).getAll() as IDBRequest + ); + }, + async get(id) { + const found = await idbRequest( + (await store(STORE_CONNECTIONS, 'readonly')).get(id) as IDBRequest< + ConnectionEntry | undefined + > + ); + return found ?? null; + }, + async put(entry) { + await idbRequest((await store(STORE_CONNECTIONS, 'readwrite')).put(entry)); + }, + async delete(id) { + await idbRequest((await store(STORE_CONNECTIONS, 'readwrite')).delete(id)); + }, + async getMeta(key) { + const found = await idbRequest( + (await store(STORE_META, 'readonly')).get(key) as IDBRequest + ); + return found ?? null; + }, + async setMeta(key, value) { + const metaStore = await store(STORE_META, 'readwrite'); + if (value === null) await idbRequest(metaStore.delete(key)); + else await idbRequest(metaStore.put(value, key)); + }, + }; +} + +// ── Store ──────────────────────────────────────────────────────────────── + +export function createConnectionStore(options: { storage: ConnectionStorage }): ConnectionStore { + const { storage } = options; + + async function requireEntry(id: string): Promise { + const entry = await storage.get(id); + if (!entry) throw new Error(`Unknown connection: ${id}`); + return entry; + } + + const store: ConnectionStore = { + list() { + return storage.getAll(); + }, + + get(id) { + return storage.get(id); + }, + + async add(input) { + const id = input.id ?? crypto.randomUUID(); + if (await storage.get(id)) throw new Error(`Connection already exists: ${id}`); + const entry: ConnectionEntry = { ...input, id }; + await storage.put(entry); + return clone(entry); + }, + + async update(id, patch) { + const entry = await requireEntry(id); + if (entry.locked) throw new Error(`Connection is locked (config-owned): ${id}`); + const updated: ConnectionEntry = { ...entry, ...patch, id }; + await storage.put(updated); + return clone(updated); + }, + + async remove(id) { + const entry = await requireEntry(id); + if (entry.locked) throw new Error(`Connection is locked (config-owned): ${id}`); + await storage.delete(id); + if ((await storage.getMeta(ACTIVE_ID_KEY)) === id) { + await storage.setMeta(ACTIVE_ID_KEY, null); + } + }, + + getActiveId() { + return storage.getMeta(ACTIVE_ID_KEY); + }, + + async getActive() { + const id = await storage.getMeta(ACTIVE_ID_KEY); + return id === null ? null : storage.get(id); + }, + + async setActive(id) { + await requireEntry(id); + await storage.setMeta(ACTIVE_ID_KEY, id); + }, + + async seedFromRuntimeConfig(config) { + if (!config) return; + const activeId = await storage.getMeta(ACTIVE_ID_KEY); + const configIds = new Set(config.connections.map((entry) => entry.id)); + for (const existing of await storage.getAll()) { + if (!existing.locked || configIds.has(existing.id)) continue; + await storage.delete(existing.id); + if (activeId === existing.id) { + await storage.setMeta(ACTIVE_ID_KEY, null); + } + } + for (const entry of config.connections) { + const existing = await storage.get(entry.id); + // Config wins for the entries it owns (locked), including on + // re-seed; a same-id entry the user somehow owns is left alone. + if (!existing || existing.locked) await storage.put(clone(entry)); + } + if ((await storage.getMeta(ACTIVE_ID_KEY)) !== null) return; + const fallback = config.connections.find((entry) => entry.isDefault); + if (fallback && (await storage.get(fallback.id))) { + await storage.setMeta(ACTIVE_ID_KEY, fallback.id); + } + }, + }; + + return store; +} + +// ── Runtime config ─────────────────────────────────────────────────────── + +/** + * Fetch '/runtime-config.json' from the app's OWN origin (relative URL — the + * static server ships the file beside the build; the client holds no other + * trusted origin at boot). Absent (404), unreachable (offline), or malformed + * -> null; never throws, so an offline PWA boot still reaches the stored + * connection list (plan §6.10). + */ +export async function loadRuntimeConfig( + fetchImpl: typeof globalThis.fetch = globalThis.fetch +): Promise { + try { + const response = await fetchImpl('/runtime-config.json', { + method: 'GET', + headers: { accept: 'application/json' }, + credentials: 'omit', + }); + if (!response.ok) return null; + const parsed = (await response.json()) as { connections?: unknown } | null; + if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.connections)) return null; + return adaptRuntimeConfigForBrowser(parsed as RuntimeConfig); + } catch { + return null; + } +} diff --git a/packages/client/src/lib/connections/secrets.ts b/packages/client/src/lib/connections/secrets.ts new file mode 100644 index 000000000..f4d569169 --- /dev/null +++ b/packages/client/src/lib/connections/secrets.ts @@ -0,0 +1,62 @@ +/** + * Per-connection credential material (plan ui-runtime-modes-plan.md §6.6, + * §6.8): a ConnectionEntry carries only `auth.secretRef` — the actual + * Basic/Bearer material lives here, in the same client-side storage backend + * (IndexedDB in the browser), keyed by that ref. These are CONNECTION + * credentials the user pasted for a guardian/OpenCode endpoint — never host + * credentials (§8.10). + */ +import type { ConnectionAuth } from '../transport/index.js'; +import type { ConnectionEntry, ConnectionStorage } from './index.js'; + +export type SecretMaterial = { + username?: string; + password?: string; + token?: string; +}; + +export type SecretStore = { + set(ref: string, material: SecretMaterial): Promise; + delete(ref: string): Promise; + /** + * Build the transport auth for a connection. Missing or unreadable secret + * material degrades to { mode: 'none' } — the health probe then reports + * 'unauthorized' instead of the app crashing. + */ + resolveAuth(entry: ConnectionEntry): Promise; +}; + +const META_PREFIX = 'secret:'; + +export function createSecretStore(storage: ConnectionStorage): SecretStore { + return { + async set(ref, material) { + await storage.setMeta(META_PREFIX + ref, JSON.stringify(material)); + }, + + async delete(ref) { + await storage.setMeta(META_PREFIX + ref, null); + }, + + async resolveAuth(entry) { + if (entry.auth.mode === 'none' || !entry.auth.secretRef) return { mode: 'none' }; + const raw = await storage.getMeta(META_PREFIX + entry.auth.secretRef); + if (raw === null) return { mode: 'none' }; + let material: SecretMaterial; + try { + material = JSON.parse(raw) as SecretMaterial; + } catch { + return { mode: 'none' }; + } + if (entry.auth.mode === 'basic' && typeof material.password === 'string') { + return material.username + ? { mode: 'basic', username: material.username, password: material.password } + : { mode: 'basic', password: material.password }; + } + if (entry.auth.mode === 'bearer' && typeof material.token === 'string') { + return { mode: 'bearer', token: material.token }; + } + return { mode: 'none' }; + }, + }; +} diff --git a/packages/client/src/lib/resolve-landing.ts b/packages/client/src/lib/resolve-landing.ts new file mode 100644 index 000000000..5772357b0 --- /dev/null +++ b/packages/client/src/lib/resolve-landing.ts @@ -0,0 +1,13 @@ +/** + * Landing resolver for the client app (plan ui-runtime-modes-plan.md §6.5 + * pwa-static branch; P5b item 3, #555). + * + * The client app has no host capabilities, no LaunchState, no migration + * gate — its resolver is the §6.5 client branch only, keyed off the stored + * connection list. Pure and synchronous: the boot code awaits the store's + * list() and hands the array in; resolution is data-in, path-out (same + * discipline as packages/ui/src/lib/resolve-landing.ts). + */ +export function resolveLanding(connections: ReadonlyArray<{ id: string }>): string { + return connections.length === 0 ? '/connections/new' : '/chat'; +} diff --git a/packages/client/src/lib/transport/index.ts b/packages/client/src/lib/transport/index.ts new file mode 100644 index 000000000..6d8b3c33a --- /dev/null +++ b/packages/client/src/lib/transport/index.ts @@ -0,0 +1,260 @@ +/** + * The ONE client transport (plan ui-runtime-modes-plan.md §6.11; P5b item 1, + * #555): talk to a connection's OpenCode/guardian base URL DIRECTLY from the + * browser with the connection's own credentials. + * + * Deliberate differences from the host app's chat api + * (packages/ui/src/lib/api/chat.ts), which is the reference implementation: + * - no same-origin `/proxy/assistant/*` broker — requests go straight to + * the connection base URL, + * - no cookies, ever: every request sets `credentials: 'omit'` (the host + * app's proxy transport does the opposite, `credentials: 'include'`; + * the client holds per-connection credentials, not host cookies — + * plan §6.8/§8.10), + * - auth is derived from the connection: Basic (username defaults to + * 'openpalm', mirroring the host app's probeEndpoint() so guardian + * credentials minted by the host stack work without a username field, + * #435), Bearer, or none. + * + * Everything here is pure TS with an injectable fetch — unit-tested in + * packages/client/tests/transport-*.test.ts (the pinned contract). + */ + +export type ConnectionAuth = + | { mode: 'none' } + | { mode: 'basic'; username?: string; password: string } + | { mode: 'bearer'; token: string }; + +export type SessionSummary = { + id: string; + /** '' until OpenCode summarizes (UI renders a fallback). */ + title: string; + createdAt: number; + updatedAt: number; +}; + +export type HealthProbeResult = { + state: 'accessible' | 'unauthorized' | 'unreachable'; + detail?: string; +}; + +export type TransportOptions = { + baseUrl: string; + /** Default: { mode: 'none' }. */ + auth?: ConnectionAuth; + /** Injectable for tests; defaults to globalThis.fetch. */ + fetch?: typeof globalThis.fetch; +}; + +export type Transport = { + listSessions(): Promise; + createSession(): Promise<{ id: string }>; + sendMessage(sessionId: string, text: string): Promise; + probeHealth(): Promise; +}; + +/** One parsed SSE frame ('\n\n'-delimited; multi-line data joined with '\n'). */ +export type SseFrame = { event?: string; data?: string; id?: string }; + +/** OpenCode responses can take 30–120s (same budget as the host app). */ +const MESSAGE_TIMEOUT_MS = 150_000; +const PROBE_TIMEOUT_MS = 5_000; + +function authorizationHeader(auth: ConnectionAuth): string | null { + if (auth.mode === 'basic') { + const username = auth.username ?? 'openpalm'; + return `Basic ${btoa(`${username}:${auth.password}`)}`; + } + if (auth.mode === 'bearer') return `Bearer ${auth.token}`; + return null; +} + +export function createTransport(options: TransportOptions): Transport { + const fetchImpl = options.fetch ?? globalThis.fetch; + const auth = options.auth ?? { mode: 'none' }; + // Trailing-slash and path-prefix safe: '/opencode/' + '/session' must + // become '/opencode/session' (reverse-proxied instances keep their prefix). + const base = options.baseUrl.replace(/\/+$/, ''); + + function buildHeaders(extra?: Record): Record { + const headers: Record = { ...extra }; + const authorization = authorizationHeader(auth); + if (authorization) headers.authorization = authorization; + return headers; + } + + async function request( + method: 'GET' | 'POST', + path: string, + body?: unknown, + signal?: AbortSignal + ): Promise { + const init: RequestInit = { + method, + headers: buildHeaders(body === undefined ? undefined : { 'content-type': 'application/json' }), + // 'omit' is the only fetch credentials mode that guarantees no cookies + // — the default 'same-origin' would still leak cookies to a + // same-origin connection URL. Never 'include'. + credentials: 'omit', + }; + if (body !== undefined) init.body = JSON.stringify(body); + if (signal) init.signal = signal; + const response = await fetchImpl(`${base}${path}`, init); + if (!response.ok) { + throw Object.assign(new Error(`HTTP ${response.status}`), { status: response.status }); + } + return response; + } + + async function parseSseMessage(response: Response): Promise { + if (!response.body) return null; + let lastPayload: unknown = null; + try { + for await (const frame of parseSseStream(response.body)) { + if (!frame.data) continue; + try { + const payload = JSON.parse(frame.data) as unknown; + lastPayload = payload; + if (typeof payload === 'object' && payload !== null && 'parts' in payload) { + return payload; + } + } catch {} + } + } catch (error) { + throw error instanceof Error + ? error + : Object.assign(new Error(String(error)), { status: 502 }); + } + return lastPayload; + } + + return { + /** + * List sessions on the connection. OpenCode returns `Array` + * with no ordering guarantee; sorted desc by `time.updated` here so + * consumers can rely on it (ported from packages/ui listSessions()). + */ + async listSessions(): Promise { + const res = await request('GET', '/session'); + const raw = (await res.json()) as Array<{ + id: string; + title?: string; + time?: { created?: number; updated?: number }; + }>; + const summaries: SessionSummary[] = raw.map((s) => ({ + id: s.id, + title: s.title ?? '', + createdAt: s.time?.created ?? 0, + updatedAt: s.time?.updated ?? s.time?.created ?? 0, + })); + summaries.sort((a, b) => b.updatedAt - a.updatedAt); + return summaries; + }, + + async createSession(): Promise<{ id: string }> { + const res = await request('POST', '/session', {}); + return (await res.json()) as { id: string }; + }, + + /** POST the OpenCode parts envelope; resolves with the raw response body. */ + async sendMessage(sessionId: string, text: string): Promise { + const res = await request( + 'POST', + `/session/${encodeURIComponent(sessionId)}/message`, + { parts: [{ type: 'text', text }] }, + AbortSignal.timeout(MESSAGE_TIMEOUT_MS) + ); + const contentType = res.headers.get('content-type')?.toLowerCase() ?? ''; + if (contentType.startsWith('text/event-stream')) { + return parseSseMessage(res); + } + return (await res.json()) as unknown; + }, + + /** + * GET the connection base URL and map the outcome onto the host app's + * RemoteStatus vocabulary (packages/ui probeEndpoint()). Never throws — + * connection health is data, not an exception path. + */ + async probeHealth(): Promise { + try { + const response = await fetchImpl(`${base}/`, { + method: 'GET', + headers: buildHeaders(), + credentials: 'omit', + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }); + if (response.status === 401 || response.status === 403) { + return { state: 'unauthorized', detail: `HTTP ${response.status}` }; + } + if (response.ok || (response.status >= 300 && response.status < 400)) { + return { state: 'accessible' }; + } + return { state: 'unreachable', detail: `HTTP ${response.status}` }; + } catch (error) { + return { + state: 'unreachable', + detail: error instanceof Error ? error.message : String(error), + }; + } + }, + }; +} + +/** + * Parse one '\n\n'-delimited SSE frame body. Multi-line `data:` fields are + * concatenated with '\n' per the SSE spec; comment lines (':…') and `retry:` + * fields are ignored (same behavior as packages/ui session-events.ts + * parseFrame()). + */ +function parseFrame(chunk: string): SseFrame { + const frame: SseFrame = {}; + const dataLines: string[] = []; + for (const rawLine of chunk.split('\n')) { + if (!rawLine || rawLine.startsWith(':')) continue; // comment / heartbeat + if (rawLine.startsWith('data:')) { + dataLines.push(rawLine.replace(/^data:\s?/, '')); + } else if (rawLine.startsWith('event:')) { + frame.event = rawLine.replace(/^event:\s?/, ''); + } else if (rawLine.startsWith('id:')) { + frame.id = rawLine.replace(/^id:\s?/, ''); + } + // `retry:` is ignored — reconnect pacing is the consumer's concern. + } + if (dataLines.length > 0) frame.data = dataLines.join('\n'); + return frame; +} + +/** + * Parse a raw SSE byte stream into frames. Yields ONLY frames that carry at + * least one of event/data/id (comment-only and retry-only frames yield + * nothing); an unterminated trailing frame at end-of-stream is discarded + * (SSE spec). Frames split across chunk boundaries are buffered, and the + * decoder runs in streaming mode so multi-byte UTF-8 characters split + * mid-sequence decode correctly. + */ +export async function* parseSseStream( + stream: ReadableStream +): AsyncGenerator { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const chunks = buffer.split('\n\n'); + buffer = chunks.pop() ?? ''; + for (const chunk of chunks) { + if (!chunk) continue; + const frame = parseFrame(chunk); + if (frame.event !== undefined || frame.data !== undefined || frame.id !== undefined) { + yield frame; + } + } + } + } finally { + reader.releaseLock(); + } +} diff --git a/packages/client/src/routes/+layout.svelte b/packages/client/src/routes/+layout.svelte new file mode 100644 index 000000000..c22a46ac6 --- /dev/null +++ b/packages/client/src/routes/+layout.svelte @@ -0,0 +1,109 @@ + + + + + +
+
+ OpenPalm + +
+ +
+ {@render children?.()} +
+
+ + diff --git a/packages/client/src/routes/+layout.ts b/packages/client/src/routes/+layout.ts new file mode 100644 index 000000000..9a34d50d7 --- /dev/null +++ b/packages/client/src/routes/+layout.ts @@ -0,0 +1,8 @@ +/** + * Pure SPA (plan ui-runtime-modes-plan.md §6.10/§6.11): no SSR, no + * prerendered pages — adapter-static emits the index.html fallback and + * every route renders in the browser against IndexedDB + the per-connection + * transports. There is no server runtime in this artifact at all. + */ +export const ssr = false; +export const prerender = false; diff --git a/packages/client/src/routes/+page.svelte b/packages/client/src/routes/+page.svelte new file mode 100644 index 000000000..c4d2d2f06 --- /dev/null +++ b/packages/client/src/routes/+page.svelte @@ -0,0 +1,5 @@ + diff --git a/packages/client/src/routes/+page.ts b/packages/client/src/routes/+page.ts new file mode 100644 index 000000000..19b2e333b --- /dev/null +++ b/packages/client/src/routes/+page.ts @@ -0,0 +1,15 @@ +/** + * Root landing (plan ui-runtime-modes-plan.md §6.5, client branch): boot the + * connection store, then redirect — no stored connections means there is + * nothing to chat with yet, so land on /connections/new; otherwise /chat. + * With ssr=false this load runs in the browser after the SPA shell mounts. + */ +import { redirect } from '@sveltejs/kit'; +import { getClientBoot } from '$lib/boot.js'; +import { resolveLanding } from '$lib/resolve-landing.js'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async () => { + const { store } = await getClientBoot(); + redirect(302, resolveLanding(await store.list())); +}; diff --git a/packages/client/src/routes/chat/+page.svelte b/packages/client/src/routes/chat/+page.svelte new file mode 100644 index 000000000..526814b6e --- /dev/null +++ b/packages/client/src/routes/chat/+page.svelte @@ -0,0 +1,328 @@ + + + + Chat — OpenPalm + + +
+ + +
+
+ {#if showHistoryNote} +
Earlier messages in this session are not shown yet.
+ {/if} + {#each turns as turn, index (index)} + + {/each} + {#if sending} +
Thinking…
+ {/if} + {#if error} + + {/if} +
+
+ +
+
+
+ + diff --git a/packages/client/src/routes/connections/+page.svelte b/packages/client/src/routes/connections/+page.svelte new file mode 100644 index 000000000..c43531486 --- /dev/null +++ b/packages/client/src/routes/connections/+page.svelte @@ -0,0 +1,512 @@ + + + + Connections — OpenPalm + + +
+ + + {#if pageError} + + {/if} + +
+ {#each connections as conn (conn.id)} +
+
+
+ {conn.label} + {#if conn.locked}Managed{/if} + {#if conn.isDefault}Default{/if} + {#if conn.id === activeId}Active{/if} + {#if conn.auth.mode !== 'none'}{/if} + {healthLabel(conn.id).text} +
+
{conn.url}
+
+
+ {#if conn.id !== activeId} + + {/if} + {#if !conn.locked} + + + {/if} +
+
+ {:else} +

No connections yet — add one to start chatting.

+ {/each} +
+ + {#if formMode === 'idle'} + + {:else} +
+

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

+ + + + + + + + {#if formAuthMode === 'basic'} + + + {:else if formAuthMode === 'bearer'} + + {/if} + + {#if formMode === 'edit' && formAuthMode !== 'none'} + + {/if} + + {#if formError} + + {/if} + +
+ + +
+
+ {/if} +
+ + diff --git a/packages/client/src/routes/connections/new/+page.svelte b/packages/client/src/routes/connections/new/+page.svelte new file mode 100644 index 000000000..229a479ff --- /dev/null +++ b/packages/client/src/routes/connections/new/+page.svelte @@ -0,0 +1,6 @@ + diff --git a/packages/client/src/routes/connections/new/+page.ts b/packages/client/src/routes/connections/new/+page.ts new file mode 100644 index 000000000..fea18b8cc --- /dev/null +++ b/packages/client/src/routes/connections/new/+page.ts @@ -0,0 +1,13 @@ +/** + * /connections/new — the "no connections yet" landing (plan + * ui-runtime-modes-plan.md §6.5). resolveLanding() sends sessions without a + * stored connection here; the manager lives at /connections, so this route + * opens it with the add form expanded (same alias convention as + * packages/ui routes/connections/new). + */ +import { redirect } from '@sveltejs/kit'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = () => { + redirect(302, '/connections?new=1'); +}; diff --git a/packages/client/static/maskable-512x512.png b/packages/client/static/maskable-512x512.png new file mode 100644 index 000000000..d9bfc8445 Binary files /dev/null and b/packages/client/static/maskable-512x512.png differ diff --git a/packages/client/static/pwa-192x192.png b/packages/client/static/pwa-192x192.png new file mode 100644 index 000000000..565226b13 Binary files /dev/null and b/packages/client/static/pwa-192x192.png differ diff --git a/packages/client/static/pwa-512x512.png b/packages/client/static/pwa-512x512.png new file mode 100644 index 000000000..3c47a2cb2 Binary files /dev/null and b/packages/client/static/pwa-512x512.png differ diff --git a/packages/client/svelte.config.js b/packages/client/svelte.config.js new file mode 100644 index 000000000..0aa0da701 --- /dev/null +++ b/packages/client/svelte.config.js @@ -0,0 +1,39 @@ +import adapter from "@sveltejs/adapter-static"; +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; +import pkg from "./package.json" with { type: "json" }; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + // Pure SPA (plan ui-runtime-modes-plan.md §6.10/§6.11): every route is + // client-rendered (ssr=false in routes/+layout.ts) and the adapter emits + // an index.html fallback so any static file server — bin/serve.mjs, the + // assistant container co-process (#510), or a CDN — can serve deep links. + adapter: adapter({ + pages: "build", + assets: "build", + fallback: "index.html", + }), + serviceWorker: { + register: false, + }, + version: { name: pkg.version }, + csp: { + mode: 'hash', + directives: { + 'default-src': ['self'], + 'script-src': ['self'], + 'style-src': ['self', 'unsafe-inline', 'https://fonts.googleapis.com'], + 'font-src': ['self', 'https://fonts.gstatic.com'], + 'img-src': ['self', 'data:'], + 'connect-src': ['self', 'http:', 'https:'], + 'object-src': ['none'], + 'base-uri': ['none'], + 'frame-ancestors': ['none'], + }, + }, + }, +}; + +export default config; diff --git a/packages/client/tests/boot.test.ts b/packages/client/tests/boot.test.ts new file mode 100644 index 000000000..85a5bf9c4 --- /dev/null +++ b/packages/client/tests/boot.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; + +type MutableGlobals = typeof globalThis & { + fetch?: typeof globalThis.fetch; + indexedDB?: IDBFactory; +}; + +function seededConfigResponse(): Response { + return Response.json({ + connections: [ + { + id: 'seed-local-opencode', + label: 'This assistant', + kind: 'local-opencode', + url: 'http://127.0.0.1:4096', + auth: { mode: 'none' }, + isDefault: true, + locked: true, + }, + ], + }); +} + +function createRejectingIndexedDbFactory(): IDBFactory { + return { + open() { + const request: Partial & { error: Error | null } = { + error: null, + onerror: null, + onsuccess: null, + onupgradeneeded: null, + }; + queueMicrotask(() => { + request.error = new Error('IndexedDB open failed'); + request.onerror?.call(request as IDBOpenDBRequest, new Event('error')); + }); + return request as IDBOpenDBRequest; + }, + cmp() { + return 0; + }, + deleteDatabase() { + throw new Error('not implemented'); + }, + databases: async () => [], + } as IDBFactory; +} + +describe('getClientBoot', () => { + const originalFetch = globalThis.fetch; + const originalIndexedDb = globalThis.indexedDB; + + beforeEach(() => { + (globalThis as MutableGlobals).fetch = async () => seededConfigResponse(); + (globalThis as MutableGlobals).indexedDB = createRejectingIndexedDbFactory(); + }); + + afterEach(() => { + const globals = globalThis as MutableGlobals; + if (originalFetch === undefined) delete globals.fetch; + else globals.fetch = originalFetch; + if (originalIndexedDb === undefined) delete globals.indexedDB; + else globals.indexedDB = originalIndexedDb; + }); + + test('falls back to memory storage when IndexedDB open rejects, and a later call still resolves', async () => { + const { getClientBoot } = await import('../src/lib/boot.ts'); + + const first = await getClientBoot(); + expect(await first.store.list()).toEqual([ + { + id: 'seed-local-opencode', + label: 'This assistant', + kind: 'local-opencode', + url: 'http://127.0.0.1:4096', + auth: { mode: 'none' }, + isDefault: true, + locked: true, + }, + ]); + expect(await first.store.getActiveId()).toBe('seed-local-opencode'); + + const second = await getClientBoot(); + expect(await second.store.list()).toEqual(await first.store.list()); + expect(await second.store.getActiveId()).toBe('seed-local-opencode'); + }); +}); diff --git a/packages/client/tests/connections-seed.test.ts b/packages/client/tests/connections-seed.test.ts new file mode 100644 index 000000000..032ef17fb --- /dev/null +++ b/packages/client/tests/connections-seed.test.ts @@ -0,0 +1,261 @@ +/** + * P5b (#555) RED — runtime-config.json loading and locked/default seeding + + * the offline read path (P5b item 2, plan §6.6/§6.10/§6.11). + * + * Boot flow pinned here: + * 1. loadRuntimeConfig() fetches '/runtime-config.json' from the app's OWN + * origin (relative URL — the static server ships the file beside the + * build; the assistant container writes it in P5d). Absent (404), + * unreachable (offline), or malformed -> null. NEVER throws: an offline + * PWA boot must reach the stored connection list (plan §6.10 "offline + * launch shows the shell + saved connections, not a blank page"). + * 2. store.seedFromRuntimeConfig(config) upserts the config's entries by + * their stable ids: + * - null config = no-op, + * - re-seeding is idempotent (no duplicates), + * - config wins for locked entries (label/url updates apply on + * re-seed) — the container owns them, + * - user-added entries are untouched, + * - a seeded isDefault entry becomes active when nothing is active + * yet, but NEVER steals an explicit user selection. + * 3. Reads (list/get/getActive) hit only the storage backend — no network. + * + * RED until src/lib/connections/index.ts exists: every test fails with + * "Cannot find module …/src/lib/connections/index.ts" (missing feature). + */ +import { describe, expect, test } from 'bun:test'; +import { + type ConnectionEntry, + loadConnectionsModule, + type RuntimeConfig +} from './helpers/contract.ts'; +import { jsonResponse, recordingFetch, rejectingFetch } from './helpers/mocks.ts'; + +async function withLocationHost(hostname: string, run: () => Promise): Promise { + const originalLocation = globalThis.location; + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: { hostname } + }); + try { + return await run(); + } finally { + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: originalLocation + }); + } +} + +function seededEntry(overrides: Partial = {}): ConnectionEntry { + return { + id: 'seed-local-opencode', + label: 'This assistant', + kind: 'local-opencode', + url: 'http://127.0.0.1:4096', + auth: { mode: 'none' }, + isDefault: true, + locked: true, + ...overrides + }; +} + +async function storeWithBackend() { + const { createMemoryStorage, createConnectionStore } = await loadConnectionsModule(); + const storage = createMemoryStorage(); + return { storage, store: createConnectionStore({ storage }) }; +} + +describe('loadRuntimeConfig (P5b item 2)', () => { + test("GETs '/runtime-config.json' from the app's own origin and parses it", async () => { + const { loadRuntimeConfig } = await loadConnectionsModule(); + const config: RuntimeConfig = { connections: [seededEntry()] }; + const { fetch, calls } = recordingFetch(() => jsonResponse(config)); + const loaded = await loadRuntimeConfig(fetch); + expect(calls.length).toBe(1); + // Relative URL: the file MUST come from the serving origin, never from a + // connection URL — the client holds no other trusted origin at boot. + expect(calls[0].url).toBe('/runtime-config.json'); + expect(calls[0].method).toBe('GET'); + expect(calls[0].credentials).toBe('omit'); + expect(loaded).toEqual(config); + }); + + test('absent file (404) means no default: resolves null, does not throw', async () => { + const { loadRuntimeConfig } = await loadConnectionsModule(); + const { fetch } = recordingFetch(() => new Response('not found', { status: 404 })); + expect(await loadRuntimeConfig(fetch)).toBeNull(); + }); + + test('offline (fetch rejects) resolves null, does not throw', async () => { + const { loadRuntimeConfig } = await loadConnectionsModule(); + expect(await loadRuntimeConfig(rejectingFetch('offline'))).toBeNull(); + }); + + test('malformed JSON resolves null', async () => { + const { loadRuntimeConfig } = await loadConnectionsModule(); + const { fetch } = recordingFetch( + () => new Response('SPA fallback', { status: 200 }) + ); + expect(await loadRuntimeConfig(fetch)).toBeNull(); + }); + + test('valid JSON without a connections array is malformed: resolves null', async () => { + const { loadRuntimeConfig } = await loadConnectionsModule(); + const { fetch } = recordingFetch(() => jsonResponse({ unexpected: true })); + expect(await loadRuntimeConfig(fetch)).toBeNull(); + }); + + test('rewrites locked loopback default URLs to the current LAN browser host', async () => { + const { loadRuntimeConfig } = await loadConnectionsModule(); + const config: RuntimeConfig = { + connections: [ + seededEntry({ url: 'http://127.0.0.1:3800' }), + seededEntry({ id: 'remote', locked: false, url: 'http://127.0.0.1:4900' }) + ] + }; + const { fetch } = recordingFetch(() => jsonResponse(config)); + + const loaded = await withLocationHost('192.168.1.10', () => loadRuntimeConfig(fetch)); + + expect(loaded?.connections[0]?.url).toBe('http://192.168.1.10:3800/'); + expect(loaded?.connections[1]?.url).toBe('http://127.0.0.1:4900'); + }); + + test('keeps locked loopback default URLs unchanged on localhost clients', async () => { + const { loadRuntimeConfig } = await loadConnectionsModule(); + const config: RuntimeConfig = { connections: [seededEntry({ url: 'http://127.0.0.1:3800' })] }; + const { fetch } = recordingFetch(() => jsonResponse(config)); + + const loaded = await withLocationHost('127.0.0.1', () => loadRuntimeConfig(fetch)); + + expect(loaded?.connections[0]?.url).toBe('http://127.0.0.1:3800'); + }); +}); + +describe('seedFromRuntimeConfig (P5b item 2 — locked default)', () => { + test('null config is a no-op', async () => { + const { store } = await storeWithBackend(); + await store.seedFromRuntimeConfig(null); + expect(await store.list()).toEqual([]); + expect(await store.getActiveId()).toBeNull(); + }); + + test('seeds the config entries under their stable ids', async () => { + const { store } = await storeWithBackend(); + await store.seedFromRuntimeConfig({ connections: [seededEntry()] }); + expect(await store.get('seed-local-opencode')).toEqual(seededEntry()); + expect((await store.list()).length).toBe(1); + }); + + test('a seeded isDefault entry becomes active when nothing is active yet', async () => { + const { store } = await storeWithBackend(); + await store.seedFromRuntimeConfig({ connections: [seededEntry()] }); + expect(await store.getActiveId()).toBe('seed-local-opencode'); + expect((await store.getActive())?.locked).toBe(true); + }); + + test('re-seeding is idempotent and config wins for locked entries (label/url refresh)', async () => { + // The container rewrote runtime-config.json (new port, new label); the + // next boot must apply it to the locked entry without duplicating it. + const { store } = await storeWithBackend(); + await store.seedFromRuntimeConfig({ connections: [seededEntry()] }); + await store.seedFromRuntimeConfig({ + connections: [seededEntry({ label: 'Renamed by config', url: 'http://127.0.0.1:5096' })] + }); + const all = await store.list(); + expect(all.length).toBe(1); + expect(all[0]).toEqual( + seededEntry({ label: 'Renamed by config', url: 'http://127.0.0.1:5096' }) + ); + }); + + test('seeding never steals an explicit user selection', async () => { + const { store } = await storeWithBackend(); + const mine = await store.add({ + label: 'My remote', + kind: 'remote-opencode', + url: 'https://gw.example', + auth: { mode: 'basic', secretRef: 'sec_1' } + }); + await store.setActive(mine.id); + await store.seedFromRuntimeConfig({ connections: [seededEntry()] }); + expect(await store.getActiveId()).toBe(mine.id); + }); + + test('seeding leaves user-added entries untouched', async () => { + const { store } = await storeWithBackend(); + const mine = await store.add({ + id: 'conn-user', + label: 'My remote', + kind: 'remote-opencode', + url: 'https://gw.example', + auth: { mode: 'bearer', secretRef: 'sec_2' } + }); + await store.seedFromRuntimeConfig({ connections: [seededEntry()] }); + expect(await store.get('conn-user')).toEqual(mine); + expect((await store.list()).length).toBe(2); + }); + + test('re-seeding prunes locked entries that disappeared from runtime-config.json', async () => { + const { store } = await storeWithBackend(); + await store.seedFromRuntimeConfig({ + connections: [seededEntry(), seededEntry({ id: 'seed-removed', label: 'Old lock' })] + }); + + await store.seedFromRuntimeConfig({ connections: [seededEntry()] }); + + expect(await store.get('seed-local-opencode')).toEqual(seededEntry()); + expect(await store.get('seed-removed')).toBeNull(); + expect(await store.list()).toEqual([seededEntry()]); + }); + + test('re-seeding prunes a removed locked active entry and clears the active selection', async () => { + const { store } = await storeWithBackend(); + const removed = seededEntry({ id: 'seed-removed', label: 'Old lock', isDefault: false }); + await store.seedFromRuntimeConfig({ connections: [removed] }); + await store.setActive(removed.id); + + await store.seedFromRuntimeConfig({ connections: [seededEntry({ isDefault: false })] }); + + expect(await store.get(removed.id)).toBeNull(); + expect(await store.getActiveId()).toBeNull(); + }); + + test('seeded locked entries stay immutable through the store API', async () => { + const { store } = await storeWithBackend(); + await store.seedFromRuntimeConfig({ connections: [seededEntry()] }); + expect(store.update('seed-local-opencode', { url: 'http://evil.example' })).rejects.toThrow(); + expect(store.remove('seed-local-opencode')).rejects.toThrow(); + expect((await store.get('seed-local-opencode'))?.url).toBe('http://127.0.0.1:4096'); + }); +}); + +describe('offline read path (P5b item 2, plan §6.10)', () => { + test('offline boot: loadRuntimeConfig -> null, seed(null) no-op, stored connections still readable', async () => { + // Previous (online) session stored connections; this boot is offline. + const { createMemoryStorage, createConnectionStore, loadRuntimeConfig } = + await loadConnectionsModule(); + const storage = createMemoryStorage(); + const online = createConnectionStore({ storage }); + await online.seedFromRuntimeConfig({ connections: [seededEntry()] }); + const mine = await online.add({ + id: 'conn-user', + label: 'My remote', + kind: 'remote-opencode', + url: 'https://gw.example', + auth: { mode: 'basic', secretRef: 'sec_1' } + }); + + // Reload with no network at all. + const config = await loadRuntimeConfig(rejectingFetch('offline')); + expect(config).toBeNull(); + const offline = createConnectionStore({ storage }); + await offline.seedFromRuntimeConfig(config); + + const list = await offline.list(); + expect(list).toContainEqual(seededEntry()); + expect(list).toContainEqual(mine); + expect(await offline.getActiveId()).toBe('seed-local-opencode'); + }); +}); diff --git a/packages/client/tests/connections-store.test.ts b/packages/client/tests/connections-store.test.ts new file mode 100644 index 000000000..e2b25bee4 --- /dev/null +++ b/packages/client/tests/connections-store.test.ts @@ -0,0 +1,169 @@ +/** + * P5b (#555) RED — connection store CRUD + active selection (P5b item 2, + * plan §6.6: ConnectionEntry persisted behind a storage abstraction — + * IndexedDB in the browser, the in-memory backend here; the tests are the + * spec for BOTH backends, so everything is async and nothing may live only + * in store-instance fields). + * + * Contract pinned here: + * - add() persists a ConnectionEntry, generating an id when the input has + * none (explicit ids are kept — runtime-config seeds rely on that), + * - update()/remove() reject for unknown ids AND for locked entries + * (locked = owned by runtime-config.json, plan §6.6 `locked`), + * - active selection is persisted in the same storage (survives a new + * store instance over the same backend — the IndexedDB equivalence), + * - setActive() rejects unknown ids; removing the active entry clears the + * selection (no dangling active id), + * - nothing is implicitly activated by add() — only runtime-config + * seeding auto-selects a default (see connections-seed.test.ts). + * + * RED until src/lib/connections/index.ts exists: every test fails with + * "Cannot find module …/src/lib/connections/index.ts" (missing feature). + */ +import { describe, expect, test } from 'bun:test'; +import { loadConnectionsModule, type NewConnectionInput } from './helpers/contract.ts'; + +function guardianInput(overrides: Partial = {}): NewConnectionInput { + return { + label: 'Home guardian', + kind: 'remote-opencode', + url: 'http://gw.example:8443', + auth: { mode: 'basic', secretRef: 'sec_1' }, + ...overrides + }; +} + +async function freshStore() { + const { createMemoryStorage, createConnectionStore } = await loadConnectionsModule(); + const storage = createMemoryStorage(); + return { storage, store: createConnectionStore({ storage }) }; +} + +describe('connection store CRUD (P5b item 2)', () => { + test('starts empty: list() is [] and get() of an unknown id is null', async () => { + const { store } = await freshStore(); + expect(await store.list()).toEqual([]); + expect(await store.get('missing')).toBeNull(); + }); + + test('add() returns the stored entry with a generated non-empty id and get() round-trips it', async () => { + const { store } = await freshStore(); + const added = await store.add(guardianInput()); + expect(typeof added.id).toBe('string'); + expect(added.id.length).toBeGreaterThan(0); + expect(added).toMatchObject(guardianInput()); + expect(await store.get(added.id)).toEqual(added); + expect(await store.list()).toEqual([added]); + }); + + test('add() generates distinct ids for successive entries', async () => { + const { store } = await freshStore(); + const first = await store.add(guardianInput({ label: 'One' })); + const second = await store.add(guardianInput({ label: 'Two' })); + expect(second.id).not.toBe(first.id); + expect((await store.list()).length).toBe(2); + }); + + test('add() keeps an explicit id (runtime-config seeds depend on stable ids)', async () => { + const { store } = await freshStore(); + const added = await store.add(guardianInput({ id: 'conn-explicit' })); + expect(added.id).toBe('conn-explicit'); + expect(await store.get('conn-explicit')).toEqual(added); + }); + + test('update() patches fields, persists the result, and returns the updated entry', async () => { + const { store } = await freshStore(); + const added = await store.add(guardianInput()); + const updated = await store.update(added.id, { + label: 'Renamed', + url: 'https://gw.example:9443' + }); + expect(updated).toEqual({ ...added, label: 'Renamed', url: 'https://gw.example:9443' }); + expect(await store.get(added.id)).toEqual(updated); + }); + + test('update() rejects for an unknown id', async () => { + const { store } = await freshStore(); + expect(store.update('missing', { label: 'x' })).rejects.toThrow(); + }); + + test('update() rejects for a locked entry (config-owned, plan §6.6)', async () => { + const { store } = await freshStore(); + const locked = await store.add(guardianInput({ id: 'conn-locked', locked: true })); + expect(store.update(locked.id, { label: 'tampered' })).rejects.toThrow(); + expect((await store.get(locked.id))?.label).toBe('Home guardian'); + }); + + test('remove() deletes the entry', async () => { + const { store } = await freshStore(); + const added = await store.add(guardianInput()); + await store.remove(added.id); + expect(await store.get(added.id)).toBeNull(); + expect(await store.list()).toEqual([]); + }); + + test('remove() rejects for an unknown id', async () => { + const { store } = await freshStore(); + expect(store.remove('missing')).rejects.toThrow(); + }); + + test('remove() rejects for a locked entry and keeps it stored', async () => { + const { store } = await freshStore(); + const locked = await store.add(guardianInput({ id: 'conn-locked', locked: true })); + expect(store.remove(locked.id)).rejects.toThrow(); + expect(await store.get(locked.id)).not.toBeNull(); + }); +}); + +describe('active connection selection (P5b item 2)', () => { + test('nothing is active initially, and add() does not implicitly activate', async () => { + const { store } = await freshStore(); + expect(await store.getActiveId()).toBeNull(); + expect(await store.getActive()).toBeNull(); + await store.add(guardianInput()); + expect(await store.getActiveId()).toBeNull(); + }); + + test('setActive() selects a stored entry; getActive() resolves it', async () => { + const { store } = await freshStore(); + const a = await store.add(guardianInput({ label: 'A' })); + const b = await store.add(guardianInput({ label: 'B' })); + await store.setActive(b.id); + expect(await store.getActiveId()).toBe(b.id); + expect(await store.getActive()).toEqual(b); + await store.setActive(a.id); + expect(await store.getActiveId()).toBe(a.id); + }); + + test('setActive() rejects for an unknown id and leaves the selection unchanged', async () => { + const { store } = await freshStore(); + const a = await store.add(guardianInput()); + await store.setActive(a.id); + expect(store.setActive('missing')).rejects.toThrow(); + expect(await store.getActiveId()).toBe(a.id); + }); + + test('removing the active entry clears the selection (no dangling active id)', async () => { + const { store } = await freshStore(); + const a = await store.add(guardianInput()); + await store.setActive(a.id); + await store.remove(a.id); + expect(await store.getActiveId()).toBeNull(); + expect(await store.getActive()).toBeNull(); + }); + + test('entries and the active selection persist in the storage backend, not the store instance', async () => { + // IndexedDB equivalence: a page reload constructs a NEW store over the + // SAME persisted backend and must see identical state. + const { createMemoryStorage, createConnectionStore } = await loadConnectionsModule(); + const storage = createMemoryStorage(); + const first = createConnectionStore({ storage }); + const added = await first.add(guardianInput({ id: 'conn-persist' })); + await first.setActive(added.id); + + const second = createConnectionStore({ storage }); + expect(await second.list()).toEqual([added]); + expect(await second.getActiveId()).toBe('conn-persist'); + expect(await second.getActive()).toEqual(added); + }); +}); diff --git a/packages/client/tests/helpers/contract.ts b/packages/client/tests/helpers/contract.ts new file mode 100644 index 000000000..d2b00bd0f --- /dev/null +++ b/packages/client/tests/helpers/contract.ts @@ -0,0 +1,170 @@ +/** + * P5b (#555) — test-side contract for the @openpalm/client public surface. + * + * These types are the tests' statement of the API defined by + * docs/technical/ui-runtime-modes-plan.md (§4.3 note, §6.5, §6.6, §6.11) and + * the P5b phase spec: + * + * - src/lib/transport/index.ts ONE transport: talk to an OpenCode/guardian + * base URL with optional Basic/Bearer credentials — direct fetch from the + * browser, no proxy, NO COOKIES (`credentials: 'omit'`). Ports the minimal + * chat surface from packages/ui (session list/create, message send, SSE + * stream parsing, health probe) — see packages/ui/src/lib/api/chat.ts, + * packages/ui/src/lib/chat/session-events.ts and the status mapping in + * packages/ui/src/lib/server/endpoints.ts probeEndpoint(). + * - src/lib/connections/index.ts ConnectionEntry store (plan §6.6) behind a + * storage abstraction: IndexedDB in the browser, in-memory backend for + * tests. Locked/default entries are seeded from a runtime-config.json + * fetched from the app's own origin at boot (absent file = no default). + * - src/lib/resolve-landing.ts landing choice for the client app: no + * stored connections -> /connections/new, else /chat (plan §6.5). + * + * The production modules DO NOT EXIST YET. Every loader below dynamically + * imports the module-under-test, so until the P5b implementation lands each + * test fails with "Cannot find module …/src/lib/…" — red for the right + * reason (missing feature). Once the modules exist the loaders bind to the + * real exports and the assertions become the contract. + * + * Deliberate contract decisions encoded here (implementer: these are pinned + * by the tests, change them only by changing the tests): + * - Basic auth username defaults to 'openpalm' when the connection carries + * only a password — mirrors the host app's probeEndpoint() default so a + * guardian provisioned by the host accepts the same credentials. + * - Health probe states are 'accessible' | 'unauthorized' | 'unreachable', + * the same vocabulary as the host app's RemoteStatus. + * - The store API is async throughout (IndexedDB is async); the in-memory + * backend must behave identically so tests are the spec for both. + */ + +export type ConnectionKind = 'local-opencode' | 'remote-opencode' | 'openpalm-client-api'; + +/** Credentials handed to the transport (client-held, per connection). */ +export type ConnectionAuth = + | { mode: 'none' } + | { mode: 'basic'; username?: string; password: string } + | { mode: 'bearer'; token: string }; + +export type SessionSummary = { + id: string; + /** '' until OpenCode summarizes (UI renders a fallback). */ + title: string; + createdAt: number; + updatedAt: number; +}; + +export type HealthProbeResult = { + state: 'accessible' | 'unauthorized' | 'unreachable'; + detail?: string; +}; + +export type TransportOptions = { + baseUrl: string; + /** Default: { mode: 'none' }. */ + auth?: ConnectionAuth; + /** Injectable for tests; defaults to globalThis.fetch. */ + fetch?: typeof globalThis.fetch; +}; + +export type Transport = { + listSessions(): Promise; + createSession(): Promise<{ id: string }>; + sendMessage(sessionId: string, text: string): Promise; + probeHealth(): Promise; +}; + +/** One parsed SSE frame ('\n\n'-delimited; multi-line data joined with '\n'). */ +export type SseFrame = { event?: string; data?: string; id?: string }; + +export type TransportModule = { + createTransport(options: TransportOptions): Transport; + /** + * Parse a raw SSE byte stream into frames. Yields ONLY frames that carry at + * least one of event/data/id (comment-only and retry-only frames yield + * nothing); an unterminated trailing frame at end-of-stream is discarded + * (SSE spec; same behavior as packages/ui session-events.ts). + */ + parseSseStream(stream: ReadableStream): AsyncIterable; +}; + +/** Plan §6.6 ConnectionEntry, client-side (no host:* in grantedCapabilities' type space). */ +export type ConnectionEntry = { + id: string; + label: string; + kind: ConnectionKind; + url: string; + auth: { mode: 'none' | 'basic' | 'bearer'; secretRef?: string }; + isDefault?: boolean; + locked?: boolean; + grantedCapabilities?: string[]; +}; + +export type NewConnectionInput = Omit & { id?: string }; + +/** + * Shape of the runtime-config.json the assistant container writes beside the + * static build (P5d). Seeded entries carry explicit stable ids so re-seeding + * is idempotent. + */ +export type RuntimeConfig = { + connections: ConnectionEntry[]; +}; + +export type ConnectionStore = { + list(): Promise; + get(id: string): Promise; + /** Generates an id when the input has none. */ + add(input: NewConnectionInput): Promise; + /** Rejects for unknown ids and for locked entries. */ + update(id: string, patch: Partial>): Promise; + /** Rejects for unknown ids and for locked entries. */ + remove(id: string): Promise; + getActiveId(): Promise; + getActive(): Promise; + /** Rejects for unknown ids. */ + setActive(id: string): Promise; + /** + * Upsert the config's (locked/default) entries by id. null config = no-op. + * A seeded isDefault entry becomes active when nothing is active yet, but + * never steals an explicit user selection. Config wins for locked entries + * (label/url updates apply on re-seed); user-added entries are untouched. + */ + seedFromRuntimeConfig(config: RuntimeConfig | null): Promise; +}; + +export type ConnectionStorage = { + getAll(): Promise; + get(id: string): Promise; + put(entry: ConnectionEntry): Promise; + delete(id: string): Promise; + getMeta(key: string): Promise; + setMeta(key: string, value: string | null): Promise; +}; + +export type ConnectionsModule = { + /** In-memory storage backend (same semantics as the IndexedDB one). */ + createMemoryStorage(): ConnectionStorage; + createConnectionStore(options: { storage: ConnectionStorage }): ConnectionStore; + /** + * Fetch '/runtime-config.json' from the app's own origin. Absent (404), + * unreachable, or malformed file -> null (no default connection; offline + * boot must not crash). + */ + loadRuntimeConfig(fetchImpl?: typeof globalThis.fetch): Promise; +}; + +export type LandingModule = { + /** Plan §6.5 client branch: 0 connections -> /connections/new, else /chat. */ + resolveLanding(connections: ReadonlyArray<{ id: string }>): string; +}; + +export function loadTransportModule(): Promise { + return import('../../src/lib/transport/index.ts') as Promise; +} + +export function loadConnectionsModule(): Promise { + return import('../../src/lib/connections/index.ts') as Promise; +} + +export function loadLandingModule(): Promise { + return import('../../src/lib/resolve-landing.ts') as Promise; +} diff --git a/packages/client/tests/helpers/mocks.ts b/packages/client/tests/helpers/mocks.ts new file mode 100644 index 000000000..ae959f750 --- /dev/null +++ b/packages/client/tests/helpers/mocks.ts @@ -0,0 +1,94 @@ +/** + * Test doubles for the P5b red tests: a recording fetch (captures URL, + * method, headers, credentials, body of every call) and byte-stream builders + * for SSE parsing tests. No production code — helpers only. + */ + +export type RecordedRequest = { + url: string; + method: string; + headers: Headers; + /** undefined when the caller did not set an explicit credentials mode. */ + credentials: RequestCredentials | undefined; + body: string | null; +}; + +export type Responder = (request: RecordedRequest) => Response | Promise; + +export type RecordingFetch = { + fetch: typeof globalThis.fetch; + calls: RecordedRequest[]; +}; + +/** + * A fetch stand-in that records each request and answers via `respond` + * (default: 200 `{}`). Handles both fetch(url, init) and fetch(Request) + * call shapes, including relative-URL string inputs (which `new Request` + * would reject outside a browser). + */ +export function recordingFetch(respond?: Responder): RecordingFetch { + const calls: RecordedRequest[] = []; + const impl = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + let recorded: RecordedRequest; + if (input instanceof Request) { + recorded = { + url: input.url, + method: (init?.method ?? input.method).toUpperCase(), + headers: new Headers(init?.headers ?? input.headers), + credentials: init?.credentials ?? input.credentials, + body: input.body === null ? null : await input.clone().text() + }; + } else { + const rawBody = init?.body; + recorded = { + url: String(input), + method: (init?.method ?? 'GET').toUpperCase(), + headers: new Headers(init?.headers), + credentials: init?.credentials, + body: typeof rawBody === 'string' ? rawBody : rawBody == null ? null : String(rawBody) + }; + } + calls.push(recorded); + if (respond) return respond(recorded); + return jsonResponse({}); + }; + return { fetch: impl as typeof globalThis.fetch, calls }; +} + +export function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { 'content-type': 'application/json' } + }); +} + +/** A fetch that always rejects — proves a code path performs no network I/O. */ +export function rejectingFetch(message = 'network disabled in this test'): typeof globalThis.fetch { + const impl = async (): Promise => { + throw new TypeError(message); + }; + return impl as typeof globalThis.fetch; +} + +/** + * Build a ReadableStream from chunks. Strings are UTF-8 encoded; + * Uint8Array chunks pass through raw (used to split multi-byte characters + * across chunk boundaries). + */ +export function byteStream(chunks: Array): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(typeof chunk === 'string' ? encoder.encode(chunk) : chunk); + } + controller.close(); + } + }); +} + +export async function collect(iterable: AsyncIterable): Promise { + const items: T[] = []; + for await (const item of iterable) items.push(item); + return items; +} diff --git a/packages/client/tests/landing.test.ts b/packages/client/tests/landing.test.ts new file mode 100644 index 000000000..bbff6390a --- /dev/null +++ b/packages/client/tests/landing.test.ts @@ -0,0 +1,38 @@ +/** + * P5b (#555) RED — client landing choice (P5b item 3, plan §6.5 pwa-static + * branch). + * + * The client app has no host capabilities, no LaunchState, no migration + * gate — its resolver is the §6.5 client branch only, keyed off the stored + * connection list: + * + * 0 connections -> '/connections/new' + * >=1 connection -> '/chat' + * + * Pure and synchronous: the boot code awaits the store's list() and hands + * the array in; the resolver itself does no I/O (same discipline as + * packages/ui/src/lib/resolve-landing.ts — resolution is data-in, + * path-out). + * + * RED until src/lib/resolve-landing.ts exists: every test fails with + * "Cannot find module …/src/lib/resolve-landing.ts" (missing feature). + */ +import { describe, expect, test } from 'bun:test'; +import { loadLandingModule } from './helpers/contract.ts'; + +describe('client landing choice (P5b item 3)', () => { + test('no stored connections lands on /connections/new', async () => { + const { resolveLanding } = await loadLandingModule(); + expect(resolveLanding([])).toBe('/connections/new'); + }); + + test('one stored connection lands on /chat', async () => { + const { resolveLanding } = await loadLandingModule(); + expect(resolveLanding([{ id: 'conn-1' }])).toBe('/chat'); + }); + + test('multiple stored connections still land on /chat', async () => { + const { resolveLanding } = await loadLandingModule(); + expect(resolveLanding([{ id: 'conn-1' }, { id: 'conn-2' }, { id: 'conn-3' }])).toBe('/chat'); + }); +}); diff --git a/packages/client/tests/purity.test.ts b/packages/client/tests/purity.test.ts new file mode 100644 index 000000000..48ce282cc --- /dev/null +++ b/packages/client/tests/purity.test.ts @@ -0,0 +1,99 @@ +/** + * P5b (#555) — purity hygiene for @openpalm/client (plan §6.11 rules, §8.5, + * §8.10: "The client app never bundles @openpalm/lib and never holds host + * credentials"; host capabilities are ABSENT from the artifact, not hidden). + * + * Two layers: + * + * 1. DIST GREP (RED until `bun run client:build` exists — the build output + * is the missing feature): every file of the built static bundle under + * packages/client/build/ is scanned for forbidden markers: + * - '@openpalm/lib' -> the host library leaked into the artifact, + * - '/api/host' -> host control-plane client code leaked in (the + * client talks only to per-connection guardian/OpenCode base URLs). + * These tests intentionally FAIL (not skip) when the build directory is + * absent, so a stale/missing build can never fake a green purity check. + * Run them after `bun run client:build`. + * + * 2. SOURCE HYGIENE (characterization — already green on the inert + * scaffold, and must stay green): package.json declares no dependency on + * @openpalm/lib in any dependency group, no src/lib/server/ directory + * exists (§6.11), and no source file imports @openpalm/lib. + */ +import { describe, expect, test } from 'bun:test'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const PKG_ROOT = fileURLToPath(new URL('..', import.meta.url)); +const BUILD_DIR = join(PKG_ROOT, 'build'); + +/** Markers that must never appear anywhere in the built client bundle. */ +const FORBIDDEN_DIST_MARKERS = ['@openpalm/lib', '/api/host'] as const; + +function walk(dir: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) files.push(...walk(path)); + else if (entry.isFile()) files.push(path); + } + return files; +} + +function builtFiles(): string[] { + if (!existsSync(BUILD_DIR)) { + throw new Error( + `client build output missing at ${BUILD_DIR} — run \`bun run client:build\` first; ` + + 'the purity checks grep the BUILT bundle, not the sources' + ); + } + return walk(BUILD_DIR); +} + +/** Files whose bytes contain the marker ('latin1' keeps byte<->char 1:1, so + * ASCII markers are found even inside binary assets). */ +function offenders(files: string[], marker: string): string[] { + return files.filter((file) => readFileSync(file).toString('latin1').includes(marker)); +} + +describe('built bundle purity (dist grep — run after `bun run client:build`)', () => { + test('the build output exists and is an SPA bundle (index.html fallback + js assets)', () => { + const files = builtFiles(); + expect(files.some((file) => file.endsWith('index.html'))).toBe(true); + expect(files.some((file) => file.endsWith('.js'))).toBe(true); + }); + + for (const marker of FORBIDDEN_DIST_MARKERS) { + test(`no built file contains '${marker}'`, () => { + expect(offenders(builtFiles(), marker)).toEqual([]); + }); + } +}); + +describe('source hygiene (characterization — green on the scaffold, must stay green)', () => { + test('package.json declares no dependency on @openpalm/lib in any group', () => { + const pkg = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8')) as Record< + string, + Record | undefined + >; + for (const group of [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies' + ]) { + expect(Object.keys(pkg[group] ?? {})).not.toContain('@openpalm/lib'); + } + }); + + test('no src/lib/server/ directory exists (plan §6.11)', () => { + expect(existsSync(join(PKG_ROOT, 'src', 'lib', 'server'))).toBe(false); + }); + + test('no source file references @openpalm/lib', () => { + const srcDir = join(PKG_ROOT, 'src'); + const sources = existsSync(srcDir) ? walk(srcDir) : []; + expect(offenders(sources, '@openpalm/lib')).toEqual([]); + }); +}); diff --git a/packages/client/tests/pwa-config.test.ts b/packages/client/tests/pwa-config.test.ts new file mode 100644 index 000000000..a346143d7 --- /dev/null +++ b/packages/client/tests/pwa-config.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, test } from 'bun:test'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const PKG_ROOT = fileURLToPath(new URL('..', import.meta.url)); +const BUILD_DIR = join(PKG_ROOT, 'build'); +const STATIC_DIR = join(PKG_ROOT, 'static'); +const APP_HTML_PATH = join(PKG_ROOT, 'src', 'app.html'); + +function walk(dir: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) files.push(...walk(path)); + else if (entry.isFile()) files.push(path); + } + return files; +} + +async function loadViteConfig() { + const mod = await import('../vite.config.ts'); + return typeof mod.default === 'function' ? mod.default({ command: 'build', mode: 'test' }) : mod.default; +} + +async function loadPwaModule() { + return import('../vite.config.ts'); +} + +async function loadSvelteConfig() { + const mod = await import('../svelte.config.js'); + return mod.default; +} + +function builtFiles(): string[] { + if (!existsSync(BUILD_DIR)) { + throw new Error(`client build output missing at ${BUILD_DIR} — run \`bun run client:build\` first`); + } + return walk(BUILD_DIR); +} + +function withLocationOrigin(origin: string, run: () => T): T { + const originalLocation = globalThis.location; + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: { origin } + }); + + try { + return run(); + } finally { + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: originalLocation + }); + } +} + +function makeRequest(init: { + method?: string; + authorization?: string; + cookie?: string; + credentials?: RequestCredentials; +}): Request { + const headers = new Headers(); + if (init.authorization) headers.set('authorization', init.authorization); + if (init.cookie) headers.set('cookie', init.cookie); + + return { + method: init.method ?? 'GET', + headers, + credentials: init.credentials ?? 'omit' + } as Request; +} + +describe('PWA source config', () => { + test('SvelteKit CSP allows the app shell and its declared external font origins without opening script execution', async () => { + const config = await loadSvelteConfig(); + expect(config.kit?.csp?.mode).toBe('hash'); + expect(config.kit?.csp?.directives?.['default-src']).toEqual(['self']); + expect(config.kit?.csp?.directives?.['script-src']).toEqual(['self']); + expect(config.kit?.csp?.directives?.['style-src']).toEqual([ + 'self', + 'unsafe-inline', + 'https://fonts.googleapis.com' + ]); + expect(config.kit?.csp?.directives?.['font-src']).toEqual(['self', 'https://fonts.gstatic.com']); + expect(config.kit?.csp?.directives?.['connect-src']).toEqual(['self', 'http:', 'https:']); + expect(config.kit?.csp?.directives?.['object-src']).toEqual(['none']); + expect(config.kit?.csp?.directives?.['base-uri']).toEqual(['none']); + }); + + test('app shell explicitly wires the generated PWA registration script', () => { + const appHtml = readFileSync(APP_HTML_PATH, 'utf8'); + expect(appHtml).toContain('/registerSW.js'); + }); + + test('@vite-pwa/sveltekit is configured with the required manifest and cache rules', async () => { + const config = await loadViteConfig(); + const pluginModule = await loadPwaModule(); + const options = pluginModule.pwaOptions; + expect((config as { plugins?: unknown[] }).plugins?.length).toBeGreaterThan(1); + expect(options).toBeTruthy(); + + expect(options?.manifest?.name).toBe('OpenPalm'); + expect(options?.manifest?.short_name).toBe('OpenPalm'); + expect(options?.manifest?.display).toBe('standalone'); + expect(options?.manifest?.theme_color).toBeTruthy(); + expect(options?.manifest?.background_color).toBeTruthy(); + + expect(options?.manifest?.icons).toEqual( + expect.arrayContaining([ + expect.objectContaining({ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' }), + expect.objectContaining({ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' }), + expect.objectContaining({ + src: 'maskable-512x512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'maskable' + }) + ]) + ); + + expect(existsSync(join(STATIC_DIR, 'pwa-192x192.png'))).toBe(true); + expect(existsSync(join(STATIC_DIR, 'pwa-512x512.png'))).toBe(true); + expect(existsSync(join(STATIC_DIR, 'maskable-512x512.png'))).toBe(true); + + expect(options?.strategies).toBe('generateSW'); + expect(options?.workbox?.globPatterns).toEqual( + expect.arrayContaining(['**/*.{js,css,html,ico,png,svg,webmanifest}']) + ); + expect(options?.workbox?.globIgnores).toEqual( + expect.arrayContaining(['**/runtime-config.json']) + ); + expect(options?.workbox?.navigateFallback).toBe('/index.html'); + + const runtimeConfigRule = options?.workbox?.runtimeCaching?.find((rule) => rule.options?.cacheName === 'runtime-config'); + expect(runtimeConfigRule?.handler).toBe('NetworkFirst'); + expect(runtimeConfigRule?.urlPattern?.({ + request: makeRequest({}), + url: new URL('https://app.openpalm.dev/runtime-config.json') + })).toBe(true); + expect(runtimeConfigRule?.urlPattern?.({ + request: makeRequest({ credentials: 'include' }), + url: new URL('https://app.openpalm.dev/runtime-config.json') + })).toBe(false); + expect(runtimeConfigRule?.urlPattern?.({ + request: makeRequest({ method: 'POST', credentials: 'omit' }), + url: new URL('https://app.openpalm.dev/runtime-config.json') + })).toBe(false); + expect(runtimeConfigRule?.urlPattern?.({ + request: makeRequest({}), + url: new URL('https://app.openpalm.dev/other.json') + })).toBe(false); + + const apiRule = options?.workbox?.runtimeCaching?.find((rule) => rule.options?.cacheName === 'openpalm-public-get'); + expect(apiRule?.handler).toBe('NetworkFirst'); + expect(apiRule?.options?.cacheableResponse?.statuses).toEqual([200]); + + const sameOrigin = new URL('https://app.openpalm.dev/oc/v1/sessions'); + const crossOrigin = new URL('https://guardian.example.test/oc/v1/sessions'); + const credentialed = makeRequest({ + authorization: 'Basic abc', + cookie: 'a=b', + credentials: 'include' + }); + const sameOriginCredentialed = makeRequest({ credentials: 'same-origin' }); + const anonymous = makeRequest({ credentials: 'omit' }); + const nonGet = makeRequest({ method: 'POST' }); + expect(apiRule?.urlPattern?.({ request: credentialed, url: crossOrigin })).toBe(false); + expect(withLocationOrigin('https://app.openpalm.dev', () => ( + apiRule?.urlPattern?.({ request: sameOriginCredentialed, url: sameOrigin }) + ))).toBe(false); + expect(apiRule?.urlPattern?.({ request: anonymous, url: sameOrigin })).toBe(true); + expect(apiRule?.urlPattern?.({ request: nonGet, url: sameOrigin })).toBe(false); + }); +}); + +describe('PWA build output', () => { + test('build emits manifest, service worker, and icon assets', () => { + const files = builtFiles(); + expect(files.some((file) => file.endsWith('manifest.webmanifest'))).toBe(true); + expect(files.some((file) => file.endsWith('sw.js'))).toBe(true); + expect(files.some((file) => file.endsWith('workbox-') || file.includes('/workbox-'))).toBe(true); + expect(files.some((file) => file.endsWith('pwa-192x192.png'))).toBe(true); + expect(files.some((file) => file.endsWith('pwa-512x512.png'))).toBe(true); + expect(files.some((file) => file.endsWith('maskable-512x512.png'))).toBe(true); + expect(readFileSync(join(BUILD_DIR, 'index.html'), 'utf8')).toContain('/registerSW.js'); + }); + + test('built manifest advertises standalone installability with maskable icon support', () => { + const manifestPath = join(BUILD_DIR, 'manifest.webmanifest'); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + name?: string; + short_name?: string; + display?: string; + icons?: Array<{ src?: string; sizes?: string; purpose?: string }>; + }; + expect(manifest.name).toBe('OpenPalm'); + expect(manifest.short_name).toBe('OpenPalm'); + expect(manifest.display).toBe('standalone'); + expect(manifest.icons).toEqual( + expect.arrayContaining([ + expect.objectContaining({ src: 'pwa-192x192.png', sizes: '192x192' }), + expect.objectContaining({ src: 'pwa-512x512.png', sizes: '512x512' }), + expect.objectContaining({ src: 'maskable-512x512.png', sizes: '512x512', purpose: 'maskable' }) + ]) + ); + }); + + test('service worker preserves runtime-config freshness and avoids caching credentialed guardian/OpenCode traffic', () => { + const serviceWorker = readFileSync(join(BUILD_DIR, 'sw.js'), 'utf8'); + expect(serviceWorker).toContain('runtime-config'); + expect(serviceWorker).toContain('NetworkFirst'); + expect(serviceWorker).toContain('runtime-config.json'); + expect(serviceWorker).toContain('openpalm-public-get'); + expect(serviceWorker).not.toContain('isCredentialedRequest'); + expect(serviceWorker).not.toContain('isSameOrigin'); + expect(serviceWorker).not.toContain('isCacheablePublicGet'); + expect(serviceWorker).not.toContain('isRuntimeConfigRequest'); + expect(serviceWorker).toContain('"GET"!=='); + expect(serviceWorker).toContain('headers.get("authorization")'); + expect(serviceWorker).toContain('headers.has("cookie")'); + expect(serviceWorker).toContain('"omit"==='); + expect(serviceWorker).toContain('"same-origin"!=='); + expect(serviceWorker).toContain('origin!==location.origin'); + }); +}); diff --git a/packages/client/tests/serve.test.ts b/packages/client/tests/serve.test.ts new file mode 100644 index 000000000..c96f11dff --- /dev/null +++ b/packages/client/tests/serve.test.ts @@ -0,0 +1,98 @@ +import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// serve.mjs must survive hostile requests: a malformed percent-escape in the +// path (e.g. /%zz) makes decodeURIComponent throw, and an uncaught throw in +// the request handler kills the whole co-process that P5c/P5d rely on. + +const SERVE = fileURLToPath(new URL('../bin/serve.mjs', import.meta.url)); +const PORT = 41000 + Math.floor(Math.random() * 1000); +const BASE = `http://127.0.0.1:${PORT}`; + +let rootDir: string; +let dir: string; +let runtimeConfigDir: string; +let explicitRuntimeConfig: string; +let child: ReturnType; + +beforeAll(async () => { + rootDir = mkdtempSync(join(tmpdir(), 'op-client-serve-')); + dir = join(rootDir, 'build'); + runtimeConfigDir = rootDir; + explicitRuntimeConfig = join(rootDir, 'explicit-runtime-config.json'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'index.html'), 'ok'); + writeFileSync(join(runtimeConfigDir, 'runtime-config.json'), '{"connections":[]}'); + writeFileSync(explicitRuntimeConfig, '{"connections":[{"id":"explicit"}]}'); + child = Bun.spawn(['node', SERVE, '--port', String(PORT), '--dir', dir], { + env: { ...process.env, OP_CLIENT_RUNTIME_CONFIG: explicitRuntimeConfig }, + stdout: 'pipe', + stderr: 'pipe', + }); + // wait for the listen log + const started = Date.now(); + while (Date.now() - started < 5000) { + try { + const res = await fetch(`${BASE}/`); + if (res.ok) return; + } catch { + await new Promise((r) => setTimeout(r, 50)); + } + } + throw new Error('serve.mjs did not start'); +}); + +afterAll(() => { + child?.kill(); + rmSync(rootDir, { recursive: true, force: true }); +}); + +describe('serve.mjs resilience', () => { + it('returns 400 for a malformed percent-escape instead of crashing', async () => { + const res = await fetch(`${BASE}/%zz`); + expect(res.status).toBe(400); + }); + + it('keeps serving after the malformed request (process not killed)', async () => { + await fetch(`${BASE}/%zz`).catch(() => undefined); + const res = await fetch(`${BASE}/`); + expect(res.status).toBe(200); + expect(await res.text()).toContain('ok'); + }); + + it('still SPA-falls-back for normal unknown routes', async () => { + const res = await fetch(`${BASE}/connections/new`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/html'); + }); + + it('supports HEAD requests with no response body', async () => { + const res = await fetch(`${BASE}/`, { method: 'HEAD' }); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/html'); + expect(await res.text()).toBe(''); + }); + + it('serves runtime-config.json with no-store caching', async () => { + const res = await fetch(`${BASE}/runtime-config.json`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('application/json'); + expect(res.headers.get('cache-control')).toBe('no-store'); + expect(await res.text()).toContain('explicit'); + }); + + it('also serves runtime-config.json from the build dir with no-store caching', async () => { + unlinkSync(join(runtimeConfigDir, 'runtime-config.json')); + unlinkSync(explicitRuntimeConfig); + writeFileSync(join(dir, 'runtime-config.json'), '{"connections":[{"id":"build"}]}'); + + const res = await fetch(`${BASE}/runtime-config.json`); + + expect(res.status).toBe(200); + expect(res.headers.get('cache-control')).toBe('no-store'); + expect(await res.text()).toContain('build'); + }); +}); diff --git a/packages/client/tests/transport-health.test.ts b/packages/client/tests/transport-health.test.ts new file mode 100644 index 000000000..22bc53bf8 --- /dev/null +++ b/packages/client/tests/transport-health.test.ts @@ -0,0 +1,95 @@ +/** + * P5b (#555) RED — health probe status mapping (P5b item 1: "health probe"). + * + * probeHealth() GETs the connection base URL with the connection credentials + * and maps the outcome onto the same state vocabulary the host app uses for + * connection probing (packages/ui/src/lib/server/endpoints.ts + * probeEndpoint(): RemoteStatus): + * - 2xx and 3xx -> 'accessible' + * - 401 / 403 -> 'unauthorized' (detail 'HTTP ') + * - other HTTP status -> 'unreachable' (detail 'HTTP ') + * - network failure -> 'unreachable' (detail = error message) + * + * The probe never throws — connection health is data, not an exception path + * (the /connections list renders it for every stored connection, offline + * included). + * + * RED until src/lib/transport/index.ts exists: every test fails with + * "Cannot find module …/src/lib/transport/index.ts" (missing feature). + */ +import { describe, expect, test } from 'bun:test'; +import { loadTransportModule } from './helpers/contract.ts'; +import { recordingFetch } from './helpers/mocks.ts'; + +const BASE = 'http://gw.example:8443'; + +function statusResponse(status: number): Response { + return new Response(status === 204 ? null : 'x', { status }); +} + +describe('transport health probe status mapping (P5b item 1)', () => { + test('HTTP 200 maps to accessible', async () => { + const { createTransport } = await loadTransportModule(); + const { fetch } = recordingFetch(() => statusResponse(200)); + const result = await createTransport({ baseUrl: BASE, fetch }).probeHealth(); + expect(result.state).toBe('accessible'); + }); + + test('a 3xx redirect maps to accessible (endpoint is alive)', async () => { + const { createTransport } = await loadTransportModule(); + const { fetch } = recordingFetch(() => statusResponse(302)); + const result = await createTransport({ baseUrl: BASE, fetch }).probeHealth(); + expect(result.state).toBe('accessible'); + }); + + test('HTTP 401 maps to unauthorized with the HTTP status as detail', async () => { + const { createTransport } = await loadTransportModule(); + const { fetch } = recordingFetch(() => statusResponse(401)); + const result = await createTransport({ baseUrl: BASE, fetch }).probeHealth(); + expect(result.state).toBe('unauthorized'); + expect(result.detail).toBe('HTTP 401'); + }); + + test('HTTP 403 maps to unauthorized', async () => { + const { createTransport } = await loadTransportModule(); + const { fetch } = recordingFetch(() => statusResponse(403)); + const result = await createTransport({ baseUrl: BASE, fetch }).probeHealth(); + expect(result.state).toBe('unauthorized'); + expect(result.detail).toBe('HTTP 403'); + }); + + test('HTTP 500 maps to unreachable with the HTTP status as detail', async () => { + const { createTransport } = await loadTransportModule(); + const { fetch } = recordingFetch(() => statusResponse(500)); + const result = await createTransport({ baseUrl: BASE, fetch }).probeHealth(); + expect(result.state).toBe('unreachable'); + expect(result.detail).toBe('HTTP 500'); + }); + + test('a network failure maps to unreachable with the error message as detail (never throws)', async () => { + const { createTransport } = await loadTransportModule(); + const failing = (async () => { + throw new TypeError('connection refused'); + }) as unknown as typeof globalThis.fetch; + const result = await createTransport({ baseUrl: BASE, fetch: failing }).probeHealth(); + expect(result.state).toBe('unreachable'); + expect(result.detail).toContain('connection refused'); + }); + + test('probes the connection base URL itself, with credentials and no cookies', async () => { + const { createTransport } = await loadTransportModule(); + const { fetch, calls } = recordingFetch(() => statusResponse(200)); + await createTransport({ + baseUrl: BASE, + auth: { mode: 'bearer', token: 'tok_1' }, + fetch + }).probeHealth(); + expect(calls.length).toBe(1); + expect(calls[0].method).toBe('GET'); + const probed = new URL(calls[0].url); + expect(probed.origin).toBe(BASE); + expect(probed.pathname).toBe('/'); + expect(calls[0].headers.get('authorization')).toBe('Bearer tok_1'); + expect(calls[0].credentials).toBe('omit'); + }); +}); diff --git a/packages/client/tests/transport-request.test.ts b/packages/client/tests/transport-request.test.ts new file mode 100644 index 000000000..18427a7a0 --- /dev/null +++ b/packages/client/tests/transport-request.test.ts @@ -0,0 +1,198 @@ +/** + * P5b (#555) RED — transport request shaping (plan §6.11: "ONE transport: + * guardian/OpenCode base URL + credentials"; P5b item 1). + * + * The client talks to a connection's base URL DIRECTLY from the browser: no + * same-origin proxy, no op_session cookie. These tests pin: + * - base URL + API path joining (trailing slash / path-prefix safe), + * - Basic auth header derived from connection credentials (username + * defaults to 'openpalm', mirroring the host app's probeEndpoint()), + * - Bearer mode, + * - cookie hygiene: every request sets credentials 'omit' and never sends + * a cookie header (§6.8/§8.10 — the client holds per-connection + * credentials, not host cookies), + * - the minimal ported chat surface: session list/create + message send + * (packages/ui/src/lib/api/chat.ts is the reference implementation). + * + * RED until src/lib/transport/index.ts exists: every test fails with + * "Cannot find module …/src/lib/transport/index.ts" (missing feature). + */ +import { describe, expect, test } from 'bun:test'; +import { loadTransportModule } from './helpers/contract.ts'; +import { byteStream, jsonResponse, recordingFetch } from './helpers/mocks.ts'; + +const BASE = 'http://gw.example:8443'; + +describe('transport request shaping (P5b item 1)', () => { + test('joins the base URL and API paths without doubled or dropped slashes', async () => { + const { createTransport } = await loadTransportModule(); + const cases: Array<{ baseUrl: string; expected: string }> = [ + { baseUrl: 'http://gw.example:8443', expected: 'http://gw.example:8443/session' }, + { baseUrl: 'http://gw.example:8443/', expected: 'http://gw.example:8443/session' }, + // Reverse-proxied instance under a path prefix — the prefix must survive. + { baseUrl: 'https://proxy.example/opencode', expected: 'https://proxy.example/opencode/session' }, + { baseUrl: 'https://proxy.example/opencode/', expected: 'https://proxy.example/opencode/session' } + ]; + for (const { baseUrl, expected } of cases) { + const { fetch, calls } = recordingFetch(() => jsonResponse([])); + const transport = createTransport({ baseUrl, fetch }); + await transport.listSessions(); + expect(calls.length).toBe(1); + expect(calls[0].url).toBe(expected); + expect(calls[0].method).toBe('GET'); + } + }); + + test('basic mode sends an Authorization header derived from the connection credentials', async () => { + const { createTransport } = await loadTransportModule(); + const { fetch, calls } = recordingFetch(() => jsonResponse([])); + const transport = createTransport({ + baseUrl: BASE, + auth: { mode: 'basic', username: 'alice', password: 's3cret' }, + fetch + }); + await transport.listSessions(); + expect(calls[0].headers.get('authorization')).toBe(`Basic ${btoa('alice:s3cret')}`); + }); + + test('basic mode defaults the username to "openpalm" when only a password is held', async () => { + // Mirrors the host app's probeEndpoint() default so credentials minted by + // the host stack (guardian Basic auth, #435) work without a username field. + const { createTransport } = await loadTransportModule(); + const { fetch, calls } = recordingFetch(() => jsonResponse([])); + const transport = createTransport({ + baseUrl: BASE, + auth: { mode: 'basic', password: 'hunter2' }, + fetch + }); + await transport.listSessions(); + expect(calls[0].headers.get('authorization')).toBe(`Basic ${btoa('openpalm:hunter2')}`); + }); + + test('bearer mode sends a Bearer Authorization header', async () => { + const { createTransport } = await loadTransportModule(); + const { fetch, calls } = recordingFetch(() => jsonResponse([])); + const transport = createTransport({ + baseUrl: BASE, + auth: { mode: 'bearer', token: 'tok_12345' }, + fetch + }); + await transport.listSessions(); + expect(calls[0].headers.get('authorization')).toBe('Bearer tok_12345'); + }); + + test('mode none sends no Authorization header', async () => { + const { createTransport } = await loadTransportModule(); + const { fetch, calls } = recordingFetch(() => jsonResponse([])); + const transport = createTransport({ baseUrl: BASE, auth: { mode: 'none' }, fetch }); + await transport.listSessions(); + expect(calls[0].headers.get('authorization')).toBeNull(); + }); + + test('never sends cookies: every request sets credentials "omit" and no cookie header', async () => { + // 'omit' is the only fetch credentials mode that guarantees no cookies — + // the default ('same-origin') would still leak cookies to a same-origin + // connection URL. The host app's proxy transport does the opposite + // (credentials 'include'); the client must never inherit that. + const { createTransport } = await loadTransportModule(); + const { fetch, calls } = recordingFetch((request) => { + if (request.method === 'POST' && request.url.endsWith('/session')) { + return jsonResponse({ id: 'ses_new' }); + } + return jsonResponse([]); + }); + const transport = createTransport({ + baseUrl: BASE, + auth: { mode: 'basic', password: 'hunter2' }, + fetch + }); + await transport.listSessions(); + await transport.createSession(); + await transport.sendMessage('ses_new', 'hello'); + await transport.probeHealth(); + expect(calls.length).toBe(4); + for (const call of calls) { + expect(call.credentials).toBe('omit'); + expect(call.headers.get('cookie')).toBeNull(); + } + }); + + test('createSession POSTs {base}/session and returns the created id', async () => { + const { createTransport } = await loadTransportModule(); + const { fetch, calls } = recordingFetch(() => jsonResponse({ id: 'ses_abc' })); + const transport = createTransport({ baseUrl: BASE, fetch }); + const created = await transport.createSession(); + expect(created).toEqual({ id: 'ses_abc' }); + expect(calls[0].method).toBe('POST'); + expect(calls[0].url).toBe(`${BASE}/session`); + }); + + test('sendMessage POSTs the OpenCode parts envelope to the session message path', async () => { + const { createTransport } = await loadTransportModule(); + const { fetch, calls } = recordingFetch(() => jsonResponse({ parts: [] })); + const transport = createTransport({ baseUrl: BASE, fetch }); + await transport.sendMessage('ses_abc', 'hi there'); + expect(calls[0].method).toBe('POST'); + expect(calls[0].url).toBe(`${BASE}/session/ses_abc/message`); + expect(calls[0].headers.get('content-type')).toContain('application/json'); + expect(JSON.parse(calls[0].body ?? '')).toEqual({ + parts: [{ type: 'text', text: 'hi there' }] + }); + }); + + test('sendMessage URL-encodes the session id path segment', async () => { + const { createTransport } = await loadTransportModule(); + const { fetch, calls } = recordingFetch(() => jsonResponse({ parts: [] })); + const transport = createTransport({ baseUrl: BASE, fetch }); + await transport.sendMessage('ses/../etc', 'x'); + expect(calls[0].url).toBe(`${BASE}/session/${encodeURIComponent('ses/../etc')}/message`); + }); + + test('sendMessage parses text/event-stream responses', async () => { + const { createTransport } = await loadTransportModule(); + const { fetch, calls } = recordingFetch(() => + new Response(byteStream(['event: message\ndata: {"parts":[{"type":"text","text":"via sse"}]}\n\n']), { + headers: { 'content-type': 'text/event-stream' }, + }) + ); + const transport = createTransport({ baseUrl: BASE, fetch }); + const response = await transport.sendMessage('ses_abc', 'hi there'); + expect(calls[0].headers.get('content-type')).toContain('application/json'); + expect(response).toEqual({ parts: [{ type: 'text', text: 'via sse' }] }); + }); + + test('listSessions maps OpenCode sessions and sorts desc by updatedAt (title fallback "")', async () => { + // Ported contract from packages/ui listSessions(): OpenCode returns + // Array with no ordering guarantee; consumers rely on desc + // `time.updated` ordering and '' title fallback. + const { createTransport } = await loadTransportModule(); + const { fetch } = recordingFetch(() => + jsonResponse([ + { id: 'a', title: 'Older', time: { created: 1, updated: 10 } }, + { id: 'b', time: { created: 5 } }, + { id: 'c', title: 'Newest', time: { created: 2, updated: 99 } } + ]) + ); + const transport = createTransport({ baseUrl: BASE, fetch }); + const sessions = await transport.listSessions(); + expect(sessions).toEqual([ + { id: 'c', title: 'Newest', createdAt: 2, updatedAt: 99 }, + { id: 'a', title: 'Older', createdAt: 1, updatedAt: 10 }, + { id: 'b', title: '', createdAt: 5, updatedAt: 5 } + ]); + }); + + test('non-ok responses reject with the HTTP status attached', async () => { + const { createTransport } = await loadTransportModule(); + const { fetch } = recordingFetch(() => jsonResponse({ error: 'nope' }, 500)); + const transport = createTransport({ baseUrl: BASE, fetch }); + let caught: unknown; + try { + await transport.listSessions(); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as { status?: number }).status).toBe(500); + }); +}); diff --git a/packages/client/tests/transport-sse.test.ts b/packages/client/tests/transport-sse.test.ts new file mode 100644 index 000000000..8dbc0bfeb --- /dev/null +++ b/packages/client/tests/transport-sse.test.ts @@ -0,0 +1,105 @@ +/** + * P5b (#555) RED — SSE stream parsing in the client transport (P5b item 1: + * "message send with SSE streaming", ported from the hand-rolled consumer in + * packages/ui/src/lib/chat/session-events.ts). + * + * parseSseStream(stream) consumes a raw byte stream and yields parsed frames + * ({ event?, data?, id? }). Contract pinned here (matches the ui reference + * implementation and the SSE spec): + * - frames are '\n\n'-delimited; multi-line `data:` joined with '\n', + * - comment/heartbeat lines (':…') and `retry:` fields are ignored, + * - frames split across chunk boundaries are buffered, including + * multi-byte UTF-8 characters split mid-sequence, + * - comment-only frames yield nothing, + * - an unterminated trailing frame at end-of-stream is discarded. + * + * All streams here are mock ReadableStreams (no network). + * + * RED until src/lib/transport/index.ts exists: every test fails with + * "Cannot find module …/src/lib/transport/index.ts" (missing feature). + */ +import { describe, expect, test } from 'bun:test'; +import { loadTransportModule } from './helpers/contract.ts'; +import { byteStream, collect } from './helpers/mocks.ts'; + +describe('transport SSE stream parsing (P5b item 1)', () => { + test('parses a complete frame into event/data/id', async () => { + const { parseSseStream } = await loadTransportModule(); + const frames = await collect( + parseSseStream(byteStream(['event: message\ndata: {"a":1}\nid: 7\n\n'])) + ); + expect(frames).toEqual([{ event: 'message', data: '{"a":1}', id: '7' }]); + }); + + test('joins multi-line data with newlines (SSE spec)', async () => { + const { parseSseStream } = await loadTransportModule(); + const frames = await collect( + parseSseStream(byteStream(['data: line one\ndata: line two\n\n'])) + ); + expect(frames.length).toBe(1); + expect(frames[0].data).toBe('line one\nline two'); + }); + + test('accepts "data:" with no space after the colon', async () => { + const { parseSseStream } = await loadTransportModule(); + const frames = await collect(parseSseStream(byteStream(['data:tight\n\n']))); + expect(frames.length).toBe(1); + expect(frames[0].data).toBe('tight'); + }); + + test('ignores comment/heartbeat lines and retry: fields', async () => { + const { parseSseStream } = await loadTransportModule(); + const frames = await collect( + parseSseStream(byteStream([': heartbeat\nretry: 3000\ndata: ok\n\n'])) + ); + expect(frames).toEqual([{ data: 'ok' }]); + }); + + test('comment-only frames yield nothing', async () => { + const { parseSseStream } = await loadTransportModule(); + const frames = await collect(parseSseStream(byteStream([': ping\n\n', ': ping\n\n']))); + expect(frames).toEqual([]); + }); + + test('parses multiple frames from a single chunk', async () => { + const { parseSseStream } = await loadTransportModule(); + const frames = await collect( + parseSseStream(byteStream(['data: one\n\ndata: two\n\ndata: three\n\n'])) + ); + expect(frames.map((frame) => frame.data)).toEqual(['one', 'two', 'three']); + }); + + test('buffers a frame split across chunk boundaries', async () => { + const { parseSseStream } = await loadTransportModule(); + const frames = await collect( + parseSseStream( + byteStream(['event: message\nda', 'ta: {"x":1}\n\nevent: done\n', 'data: {}\n\n']) + ) + ); + expect(frames).toEqual([ + { event: 'message', data: '{"x":1}' }, + { event: 'done', data: '{}' } + ]); + }); + + test('decodes multi-byte UTF-8 characters split across chunks', async () => { + // '中' is 3 UTF-8 bytes, '€' is 3 — cut mid-'中' so a byte-naive decoder + // would corrupt the text. The parser must decode in streaming mode. + const { parseSseStream } = await loadTransportModule(); + const bytes = new TextEncoder().encode('data: 中€\n\n'); + const cut = 7; // 'data: ' = 6 bytes, +1 lands inside the 3-byte '中' + const frames = await collect( + parseSseStream(byteStream([bytes.slice(0, cut), bytes.slice(cut)])) + ); + expect(frames.length).toBe(1); + expect(frames[0].data).toBe('中€'); + }); + + test('discards an unterminated trailing frame at end of stream', async () => { + const { parseSseStream } = await loadTransportModule(); + const frames = await collect( + parseSseStream(byteStream(['data: complete\n\ndata: incomplete'])) + ); + expect(frames.map((frame) => frame.data)).toEqual(['complete']); + }); +}); diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 000000000..89ab3175d --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + }, + // The generated include also pulls in tests/** — but the bun:test suite + // uses bun's module rules (bun:test builtin, explicit .ts-extension + // imports) that the DOM-lib svelte-check program rejects. The check gates + // the app source; `bun test` executes (and thereby exercises) the tests. + "include": [ + ".svelte-kit/ambient.d.ts", + ".svelte-kit/non-ambient.d.ts", + ".svelte-kit/types/**/$types.d.ts", + "vite.config.ts", + "svelte.config.js", + "src/**/*.js", + "src/**/*.ts", + "src/**/*.svelte" + ] +} diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts new file mode 100644 index 000000000..3af10e8af --- /dev/null +++ b/packages/client/vite.config.ts @@ -0,0 +1,99 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { SvelteKitPWA, type SvelteKitPWAOptions } from '@vite-pwa/sveltekit'; +import { defineConfig } from 'vite'; + +export const PWA_ICON_ENTRIES = [ + { + src: 'pwa-192x192.png', + sizes: '192x192', + type: 'image/png', + }, + { + src: 'pwa-512x512.png', + sizes: '512x512', + type: 'image/png', + }, + { + src: 'maskable-512x512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'maskable', + }, +]; + +export const pwaOptions: Partial = { + strategies: 'generateSW', + injectRegister: 'auto', + registerType: 'autoUpdate', + includeAssets: ['pwa-192x192.png', 'pwa-512x512.png', 'maskable-512x512.png'], + manifest: { + name: 'OpenPalm', + short_name: 'OpenPalm', + description: 'OpenPalm client app for chat and connection switching.', + display: 'standalone', + start_url: '/', + scope: '/', + background_color: '#f9fafb', + theme_color: '#f9fafb', + icons: [...PWA_ICON_ENTRIES], + }, + workbox: { + globPatterns: ['**/*.{js,css,html,ico,png,svg,webmanifest}'], + globIgnores: ['**/runtime-config.json'], + navigateFallback: '/index.html', + runtimeCaching: [ + { + urlPattern: ({ request, url }) => { + if (request.method !== 'GET') return false; + if (request.headers.get('authorization') || request.headers.has('cookie')) return false; + if (request.credentials !== 'omit') return false; + return url.pathname === '/runtime-config.json'; + }, + handler: 'NetworkFirst', + options: { + cacheName: 'runtime-config', + cacheableResponse: { statuses: [200] }, + }, + }, + { + urlPattern: ({ request, url }) => { + if (request.method !== 'GET') return false; + + const authorization = request.headers.get('authorization'); + const hasCookieHeader = request.headers.has('cookie'); + if (authorization || hasCookieHeader || request.credentials === 'include') { + return false; + } + + if ( + request.credentials === 'same-origin' && + typeof location !== 'undefined' && + url.origin === location.origin + ) { + return false; + } + + return url.protocol === 'http:' || url.protocol === 'https:'; + }, + handler: 'NetworkFirst', + options: { + cacheName: 'openpalm-public-get', + cacheableResponse: { statuses: [200] }, + }, + }, + ], + }, +}; + +export default defineConfig({ + plugins: [ + sveltekit(), + SvelteKitPWA(pwaOptions), + ], + optimizeDeps: { + // Raw-source Svelte package — esbuild cannot prebundle .svelte files; + // vite-plugin-svelte compiles it as part of the app instead (same + // arrangement as packages/ui). + exclude: ['@openpalm/ui-kit'], + }, +}); diff --git a/packages/electron/src/harness-contract.ts b/packages/electron/src/harness-contract.ts index af5335142..c8eda72ce 100644 --- a/packages/electron/src/harness-contract.ts +++ b/packages/electron/src/harness-contract.ts @@ -22,7 +22,7 @@ * update the `HARNESS_CONTRACT` description in the same commit. A snapshot test * fails CI until the bump is intentional. */ -export const HARNESS_CONTRACT_VERSION = 1; +export const HARNESS_CONTRACT_VERSION = 2; /** * Enumerated description of the §5.1 native contract surface. Kept as data (not @@ -52,6 +52,7 @@ export const HARNESS_CONTRACT = { invoke: [ 'restart', 'restartUiServer', + 'openLocalApp', 'launchOnLoginStatus', 'setLaunchOnLogin', 'setTrayMicRecording', diff --git a/packages/electron/src/main.ts b/packages/electron/src/main.ts index 3216c423f..3225cf6e6 100644 --- a/packages/electron/src/main.ts +++ b/packages/electron/src/main.ts @@ -8,13 +8,18 @@ import { resolveOpenPalmHome, resolveDataDir, resolveUiBuildDir, + resolveClientBuildDir, seedUiBuild, ensureHomeDirs, checkAndUpdateUiBuild, + checkAndUpdateClientBuild, checkAndUpdateSkeleton, uiUpdateChannel, parseEnvFile, PLATFORM_VERSION, + resolveClientAppPort, + resolveClientAppUrl, + writeClientRuntimeConfig, waitForReady as libWaitForReady, restoreUiBackup, UiSupervisor, @@ -130,12 +135,17 @@ function resolveAdminToolsPluginPath(): string { const DEFAULT_UI_PORT = 3880; const DEFAULT_ASSISTANT_PORT = '3800'; const UI_PORT = Number(process.env.OP_HOST_UI_PORT) || DEFAULT_UI_PORT; +const CLIENT_PORT = resolveClientAppPort(process.env); const READY_TIMEOUT_MS = 60_000; +// Bound on the client-health probe in resolveInitialUrl — a dead client must +// not stall window creation (the fallback to the host UI chat covers it). +const CLIENT_PROBE_TIMEOUT_MS = 1_500; const MIC_SHORTCUT = 'CommandOrControl+Shift+M'; const APP_USER_MODEL_ID = 'com.openpalm.app'; let mainWindow: BrowserWindow | null = null; let uiProcess: ChildProcess | null = null; +let clientProcess: ChildProcess | null = null; let localOpencode: LocalOpencodeHandle | null = null; let registeredMicShortcut: string | null = null; // Whether the GitHub update check should surface prereleases (#504). Loaded from @@ -624,9 +634,94 @@ function stopUIServer(): void { try { rmSync(join(resolveDataDir(), '.ui-server.pid'), { force: true }); } catch { /* best-effort */ } } +// ── Client app static server (P5c, #555) ───────────────────────────────────── +// The @openpalm/client static SPA is served by its zero-dependency serve script +// (bin/serve.mjs, a sibling of the resolved build) on the stable loopback +// CLIENT_PORT. Spawn-env/child work only — no bridge surface, no IPC, so +// HARNESS_CONTRACT_VERSION is untouched. Non-fatal when the build is absent: +// log + skip, and resolveInitialUrl falls back to the host UI chat on 3880. + +async function ensureClientAppBuild(): Promise { + try { + const result = await checkAndUpdateClientBuild(PLATFORM_VERSION, resolveDataDir()); + if (result.updated) { + console.log(`Client app updated to v${result.latestVersion}`); + } else if (result.error) { + console.log(`Client app update check skipped: ${result.error}`); + } + } catch (err) { + console.log('Client app update check skipped:', err instanceof Error ? err.message : String(err)); + } +} + +function startClientAppServer(): void { + try { + const buildDir = resolveClientBuildDir(); + const serveScript = join(buildDir, '..', 'bin', 'serve.mjs'); + if (!existsSync(join(buildDir, 'index.html')) || !existsSync(serveScript)) { + console.log(`Client app build not found at ${buildDir} — skipping the client server (chat falls back to the host UI).`); + return; + } + const runtimeConfigPath = join(resolveDataDir(), 'client', 'runtime-config.json'); + writeClientRuntimeConfig(runtimeConfigPath, resolveAssistantUrl(resolveOpenPalmHome())); + // Same bundled-Node spawn as the UI child (no system `node` required). + // serve.mjs reads PORT / HOST / OP_CLIENT_DIR from the environment; HOST is + // pinned to loopback unconditionally — the client server has no remote + // escape hatch. + clientProcess = spawn(process.execPath, [serveScript], { + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + PORT: String(CLIENT_PORT), + HOST: '127.0.0.1', + OP_CLIENT_DIR: buildDir, + OP_CLIENT_RUNTIME_CONFIG: runtimeConfigPath, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + clientProcess.stdout?.on('data', (chunk: Buffer) => { writeChildLog(chunk.toString()); }); + clientProcess.stderr?.on('data', (chunk: Buffer) => { writeChildLog(chunk.toString()); }); + clientProcess.on('error', (err) => { + // Non-fatal: the window falls back to the host UI chat. + console.warn('Client app server process error:', err.message); + clientProcess = null; + }); + clientProcess.on('exit', (code) => { + if (code !== 0 && code !== null) console.warn(`Client app server exited with code ${code}`); + clientProcess = null; + }); + console.log(`Client app served at http://127.0.0.1:${CLIENT_PORT} (from ${buildDir})`); + } catch (err) { + console.warn('Client app server failed to start (non-fatal):', err instanceof Error ? err.message : String(err)); + clientProcess = null; + } +} + +function stopClientAppServer(): void { + const proc = clientProcess; + clientProcess = null; + // Plain kill — serve.mjs spawns no children of its own. + if (proc?.pid) { + try { proc.kill('SIGTERM'); } catch { /* already dead */ } + } +} + // ── Window management ──────────────────────────────────────────────────────── -async function resolveInitialUrl(): Promise { +/** + * Decide which page the window opens on (exported for tests — P5c, #555): + * + * setup complete + client server healthy → the CLIENT app chat + * (http://127.0.0.1:CLIENT_PORT/chat) + * setup complete + client unreachable → the HOST APP chat on UI_PORT + * (dumb fallback: probe, and on any failure use the host UI — e.g. no + * client build on disk; startClientAppServer's skip is non-fatal) + * setup incomplete → the host app's /setup wizard + * (ALWAYS the host app — the client artifact has no setup wizard, §8.10) + * setup-status probe failure → host app root (its landing guard + * redirects — existing behavior, unchanged) + */ +export async function resolveInitialUrl(): Promise { // Try to read setup status so we can land directly on the right page. // Falls back to root (which itself redirects appropriately). try { @@ -635,7 +730,13 @@ async function resolveInitialUrl(): Promise { }); if (res.ok) { const data = await res.json() as { setupComplete?: boolean }; - return `http://127.0.0.1:${UI_PORT}/${data.setupComplete ? 'chat' : 'setup'}`; + if (!data.setupComplete) return `http://127.0.0.1:${UI_PORT}/setup`; + // Prefer the client app chat when its server responds; the probe is + // short/bounded so a dead client never stalls window creation. + if (await waitForReady(CLIENT_PORT, CLIENT_PROBE_TIMEOUT_MS)) { + return `http://127.0.0.1:${CLIENT_PORT}/chat`; + } + return `http://127.0.0.1:${UI_PORT}/chat`; } } catch { // ignore; fall through to root @@ -707,6 +808,10 @@ function showWindow(): void { } } +function openLocalApp(): void { + void shell.openExternal(resolveClientAppUrl(process.env)); +} + // ── Docker preflight (deployment-review P0 #493) ────────────────────────────── // Thin wrapper around the extracted DockerPreflight so existing importers/tests // keep calling `ensureDockerReady()` from main.ts. @@ -785,6 +890,7 @@ async function setCheckPrerelease(enabled: boolean): Promise { function createTray(): void { trayController.create({ onOpen: showWindow, + onOpenLocalApp: openLocalApp, onShowLogs: () => { void shell.openPath(app.getPath('logs')); }, getLaunchOnLoginStatus: () => getLaunchOnLoginStatus(), onSetLaunchOnLogin: (enabled) => { setLaunchOnLogin(enabled); }, @@ -821,6 +927,12 @@ app.whenReady().then(async () => { return; } + // Start the @openpalm/client static server child after the UI server (P5c, + // #555). Non-fatal by design: absent build or spawn failure → log + skip, + // and resolveInitialUrl lands the window on the host UI chat instead. + await ensureClientAppBuild(); + startClientAppServer(); + // Spawn the ephemeral local OpenCode (Phase 3). Non-fatal: if the binary // is missing or spawn fails, the UI shows a sentinel and remote endpoints // continue to work. @@ -865,6 +977,10 @@ ipcMain.handle('restart-ui-server', async (): Promise => { return restartUIServer(); }); +ipcMain.handle('open-local-app', async (): Promise => { + openLocalApp(); +}); + // The UI child (admin "install UI version" route) sends SIGUSR2 to this parent // after seeding a newer data/ui. Same effect as the IPC path: respawn the UI // server child so the new lib loads (design §6.2). @@ -933,6 +1049,7 @@ app.on('before-quit', (event) => { event.preventDefault(); globalShortcut.unregisterAll(); trayController.stopAnimation(); + stopClientAppServer(); stopUIServer(); const handle = localOpencode; localOpencode = null; diff --git a/packages/electron/src/preload.ts b/packages/electron/src/preload.ts index a11583320..10602e6de 100644 --- a/packages/electron/src/preload.ts +++ b/packages/electron/src/preload.ts @@ -69,6 +69,10 @@ contextBridge.exposeInMainWorld('openpalm', { return ipcRenderer.invoke('restart-ui-server'); }, + openLocalApp(): Promise { + return ipcRenderer.invoke('open-local-app'); + }, + launchOnLoginStatus(): Promise { return ipcRenderer.invoke('launch-on-login-status'); }, diff --git a/packages/electron/src/tray.ts b/packages/electron/src/tray.ts index 855679e2b..b745a48f0 100644 --- a/packages/electron/src/tray.ts +++ b/packages/electron/src/tray.ts @@ -12,6 +12,8 @@ const RECORDING_ALPHAS = [1, 0.72, 0.42, 0.72]; export interface TrayCallbacks { /** "Open OpenPalm" — show/focus the main window. */ onOpen: () => void; + /** "Open Local App" — open the localhost client app in the system browser. */ + onOpenLocalApp: () => void; /** "Show Logs" — reveal the log directory. */ onShowLogs: () => void; /** Current launch-on-login status (drives the checkbox state + enablement). */ @@ -108,6 +110,7 @@ export class TrayController { const loginSettings = cb.getLaunchOnLoginStatus(); const contextMenu = Menu.buildFromTemplate([ { label: 'Open OpenPalm', click: () => cb.onOpen() }, + { label: 'Open Local App', click: () => cb.onOpenLocalApp() }, { label: 'Show Logs', click: () => cb.onShowLogs() }, { type: 'separator' }, { diff --git a/packages/electron/test/harness-contract-drift.test.ts b/packages/electron/test/harness-contract-drift.test.ts index 138d15561..036bfe449 100644 --- a/packages/electron/test/harness-contract-drift.test.ts +++ b/packages/electron/test/harness-contract-drift.test.ts @@ -39,9 +39,10 @@ interface Member { function extractMembers(objSrc: string): Member[] { const memberStartRe = /^ {2}(\w+)\(/gm; const starts: Array<{ name: string; idx: number }> = []; - let m: RegExpExecArray | null; - while ((m = memberStartRe.exec(objSrc))) { + let m = memberStartRe.exec(objSrc); + while (m) { starts.push({ name: m[1], idx: m.index }); + m = memberStartRe.exec(objSrc); } return starts.map(({ name, idx }) => { const endMarker = objSrc.indexOf('\n },', idx); diff --git a/packages/electron/test/initial-url.test.ts b/packages/electron/test/initial-url.test.ts new file mode 100644 index 000000000..787226d21 --- /dev/null +++ b/packages/electron/test/initial-url.test.ts @@ -0,0 +1,277 @@ +// ── P5c RED TESTS (#555) — Electron window prefers the client app ───────────── +// +// Plan Phase 5 item 3 / phase-5-completion-guide §4 P5c item 3: main.ts starts +// the @openpalm/client static server child after the UI server, and when setup +// is complete the window loads the CLIENT chat at http://127.0.0.1:3890/chat — +// FALLING BACK to the host app's chat on 3880 when the client child is not +// healthy (fallback stays dumb: probe, and on any failure use 3880). +// +// Pinned contract (these tests are the spec): +// • `resolveInitialUrl` is EXPORTED from src/main.ts (it exists today as a +// module-private function) so the preference/fallback decision is testable. +// • Setup complete + client healthy → http://127.0.0.1:3890/chat +// • Setup complete + client unreachable → http://127.0.0.1:3880/chat +// • Setup incomplete → http://127.0.0.1:3880/setup +// (ALWAYS the host app — the client artifact has no setup wizard.) +// • Setup-status probe failure → http://127.0.0.1:3880 (root; the +// host app's own landing guard redirects — existing behavior, unchanged). +// • Client health is probed over HTTP against 127.0.0.1:3890 (any path); the +// probe must be short/bounded — a dead client must not stall window +// creation (enforced here via the test timeout). +// • HARNESS_CONTRACT_VERSION stays at 1: P5c is spawn-env/child work, not +// bridge surface (characterization — already green; see the drift tests). +// +// Run via vitest (Node), NOT bun test — same reason as main.test.ts. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +// ── Mock node:fs (same shape as main.test.ts) ───────────────────────────────── +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn((p: string) => { + // Make the UI build appear present so seedUiBuild is never called. + if (String(p).endsWith('index.js')) return true; + return false; + }), + }; +}); + +// ── Mock node:child_process — never spawn a real process ───────────────────── +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + const fakeProcess = { on: vi.fn(), kill: vi.fn(), stdout: { on: vi.fn() }, stderr: { on: vi.fn() }, pid: 4242 }; + return { ...actual, spawn: vi.fn(() => fakeProcess) }; +}); + +// ── Mock electron (same shape as main.test.ts, plus session/systemPreferences) ─ +const { mockBrowserWindow } = vi.hoisted(() => ({ + mockBrowserWindow: { + loadURL: vi.fn(), + webContents: { setWindowOpenHandler: vi.fn(), send: vi.fn() }, + on: vi.fn(), + once: vi.fn(), + show: vi.fn(), + focus: vi.fn(), + isFocused: vi.fn(() => false), + hide: vi.fn(), + close: vi.fn(), + setTitle: vi.fn(), + setSize: vi.fn(), + isDestroyed: vi.fn(() => false), + }, +})); + +vi.mock('electron', () => ({ + app: { + getVersion: vi.fn(() => '0.12.0'), + quit: vi.fn(), + exit: vi.fn(), + whenReady: vi.fn(() => Promise.resolve()), + on: vi.fn(), + getAppPath: vi.fn(() => '/mock/app'), + getPath: vi.fn(() => join(tmpdir(), 'openpalm-initial-url-test-logs')), + relaunch: vi.fn(), + setAppUserModelId: vi.fn(), + getLoginItemSettings: vi.fn(() => ({ openAtLogin: false })), + setLoginItemSettings: vi.fn(), + }, + BrowserWindow: Object.assign( + function MockBrowserWindow() { return mockBrowserWindow; }, + { getAllWindows: vi.fn(() => [mockBrowserWindow]) }, + ), + contextBridge: { exposeInMainWorld: vi.fn() }, + dialog: { showErrorBox: vi.fn() }, + Tray: function MockTray() { + return { setToolTip: vi.fn(), setContextMenu: vi.fn(), on: vi.fn() }; + }, + globalShortcut: { register: vi.fn(() => false), unregisterAll: vi.fn() }, + nativeImage: { + createFromPath: vi.fn(() => ({ + toBitmap: vi.fn(() => Buffer.from([])), + getSize: vi.fn(() => ({ width: 16, height: 16 })), + })), + createFromBitmap: vi.fn(() => ({})), + }, + Menu: { buildFromTemplate: vi.fn(() => ({})) }, + shell: { openExternal: vi.fn(), openPath: vi.fn() }, + ipcMain: { handle: vi.fn(), on: vi.fn() }, + Notification: Object.assign( + function MockNotification() { return { show: vi.fn(), on: vi.fn() }; }, + { isSupported: vi.fn(() => true) }, + ), + session: { + defaultSession: { + setPermissionRequestHandler: vi.fn(), + setPermissionCheckHandler: vi.fn(), + }, + }, + systemPreferences: { + askForMediaAccess: vi.fn(async () => true), + getMediaAccessStatus: vi.fn(() => 'granted'), + }, +})); + +// ── Mock @openpalm/lib ──────────────────────────────────────────────────────── +// checkDocker HANGS deliberately: the module-scope `app.whenReady().then(...)` +// boot flow awaits ensureDockerReady() forever, so no background UI-server +// start/health-poll loop can interfere with the per-test fetch stubs below. +// resolveInitialUrl is a pure exported helper — it needs none of the boot flow. +vi.mock('@openpalm/lib', () => ({ + resolveOpenPalmHome: vi.fn(() => '/home/user/.openpalm'), + resolveDataDir: vi.fn(() => '/home/user/.openpalm/data'), + resolveConfigDir: vi.fn(() => '/home/user/.openpalm/config'), + resolveUiBuildDir: vi.fn(() => '/home/user/.openpalm/data/ui'), + seedUiBuild: vi.fn(() => Promise.resolve()), + ensureHomeDirs: vi.fn(), + checkAndUpdateUiBuild: vi.fn(() => Promise.resolve({ updated: false, latestVersion: '0.12.0' })), + checkAndUpdateClientBuild: vi.fn(() => Promise.resolve({ updated: false, latestVersion: '0.12.0' })), + checkAndUpdateSkeleton: vi.fn(() => Promise.resolve({ updated: false, latestVersion: '0.12.0' })), + uiUpdateChannel: vi.fn((v: string) => (v.includes('-') ? 'next' : 'latest')), + parseEnvFile: vi.fn(() => ({})), + PLATFORM_VERSION: 'v0.12.0', + resolveClientAppPort: vi.fn(() => 3890), + resolveClientAppUrl: vi.fn(() => 'http://127.0.0.1:3890/chat'), + writeClientRuntimeConfig: vi.fn(), + checkDocker: vi.fn(() => new Promise(() => { /* hang: freeze the boot flow */ })), + checkDockerCompose: vi.fn(() => Promise.resolve({ ok: true, stdout: '', stderr: '', code: 0 })), + // Faithful reimplementation of lib's waitForReady (poll /health; 200 or 401 == + // ready) so a resolveInitialUrl implementation that probes the client child + // through it still exercises the per-test global fetch stubs. + waitForReady: vi.fn(async (port: number, timeoutMs = 60_000): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(1000) }); + if (res.ok || res.status === 401) return true; + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, 300)); + } + return false; + }), + restoreUiBackup: vi.fn(() => ({ status: 'no-backup' as const })), + // Faithful minimal UiSupervisor stub (main.ts constructs one at module scope). + UiSupervisor: class { + private handle: unknown = null; + private restarting = false; + // biome-ignore lint/suspicious/noExplicitAny: test-only faithful stub + private readonly strategy: any; + // biome-ignore lint/suspicious/noExplicitAny: test-only faithful stub + private readonly cb: any; + // biome-ignore lint/suspicious/noExplicitAny: test-only faithful stub + constructor(opts: any) { + this.strategy = opts.strategy; + this.cb = opts.callbacks; + } + get current() { return this.handle; } + get isRestarting() { return this.restarting; } + adopt(handle: unknown) { this.handle = handle; } + detachHandle() { this.handle = null; } + markShuttingDown() { /* no-op */ } + async start() { return true; } + async restart() { return false; } + }, +})); + +// Keep the boot flow's harness-scoped side quests inert and offline. +vi.mock('../src/update-check.js', () => ({ + checkForElectronUpdate: vi.fn(async () => ({ updateAvailable: false })), + getCachedUpdateInfo: vi.fn(() => null), +})); +vi.mock('../src/settings.js', () => ({ + loadSettings: vi.fn(() => ({ checkPrerelease: false })), + saveSettings: vi.fn(), +})); +vi.mock('../src/local-opencode.js', () => ({ + startLocalOpenCode: vi.fn(() => Promise.resolve(null)), + killProcessTree: vi.fn(), +})); + +import * as main from '../src/main.js'; +import { HARNESS_CONTRACT_VERSION } from '../src/harness-contract.js'; + +// Namespace access (not a named import) so THIS file loads even before the +// export exists — each test then fails with the precise missing-feature reason +// instead of the whole file dying on an import error. +const resolveInitialUrl = (main as unknown as Record).resolveInitialUrl as + | (() => Promise) + | undefined; + +async function callResolveInitialUrl(): Promise { + if (typeof resolveInitialUrl !== 'function') { + throw new Error('resolveInitialUrl is not exported from src/main.ts yet (P5c red test)'); + } + return resolveInitialUrl(); +} + +/** + * Stub global fetch by target: + * - 127.0.0.1:3880/api/setup/status → the host app's setup status + * - 127.0.0.1:3890 (any path) → the client static server health + * Everything else fails fast (irrelevant to these assertions). + */ +function stubFetch(opts: { setup: boolean | 'error'; clientUp: boolean }) { + vi.stubGlobal('fetch', vi.fn(async (input: unknown) => { + const url = String(input); + if (url.includes('127.0.0.1:3880/api/setup/status')) { + if (opts.setup === 'error') throw new Error('ECONNREFUSED'); + return { ok: true, status: 200, json: async () => ({ setupComplete: opts.setup === true }) }; + } + if (url.includes('127.0.0.1:3890')) { + if (!opts.clientUp) throw new Error('ECONNREFUSED'); + return { ok: true, status: 200, json: async () => ({}) }; + } + throw new Error(`unexpected fetch in test: ${url}`); + })); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +// ── resolveInitialUrl — client-first with dumb 3880 fallback (P5c) ──────────── + +describe('resolveInitialUrl — prefers the client app, falls back to the host app', () => { + it('is exported from main.ts so the preference/fallback decision is testable', () => { + expect(typeof resolveInitialUrl, 'export resolveInitialUrl from src/main.ts').toBe('function'); + }); + + it('loads the CLIENT chat (127.0.0.1:3890/chat) when setup is complete and the client child is healthy', async () => { + stubFetch({ setup: true, clientUp: true }); + await expect(callResolveInitialUrl()).resolves.toBe('http://127.0.0.1:3890/chat'); + }); + + it('falls back to the HOST APP chat (127.0.0.1:3880/chat) when the client server is unreachable', { timeout: 15_000 }, async () => { + // Resilience: the client child failed to start (e.g. no client build on + // disk — the CLI/harness skip is non-fatal). The window must land on the + // packages/ui chat, which is NOT deleted in P5c (parity follow-up). + stubFetch({ setup: true, clientUp: false }); + await expect(callResolveInitialUrl()).resolves.toBe('http://127.0.0.1:3880/chat'); + }); + + it('always lands on the HOST APP setup wizard when setup is incomplete (the client has no /setup)', async () => { + // Even with a healthy client child: the client artifact structurally lacks + // host capabilities (plan §8.10) — setup must stay on the host app. + stubFetch({ setup: false, clientUp: true }); + await expect(callResolveInitialUrl()).resolves.toBe('http://127.0.0.1:3880/setup'); + }); + + it('keeps the dumb root fallback (127.0.0.1:3880) when the setup-status probe fails', { timeout: 15_000 }, async () => { + // Existing behavior, unchanged: the host app root re-routes via its own + // landing guard. No cleverness on failure paths. + stubFetch({ setup: 'error', clientUp: true }); + await expect(callResolveInitialUrl()).resolves.toBe('http://127.0.0.1:3880'); + }); +}); + +// ── characterization: harness contract version ─────────────────────────────── + +describe('harness contract version', () => { + it('HARNESS_CONTRACT_VERSION is 2 after adding the open-local-app bridge surface', () => { + expect(HARNESS_CONTRACT_VERSION).toBe(2); + }); +}); diff --git a/packages/electron/test/main.test.ts b/packages/electron/test/main.test.ts index 41e1664f0..35c1b8544 100644 --- a/packages/electron/test/main.test.ts +++ b/packages/electron/test/main.test.ts @@ -136,9 +136,14 @@ vi.mock('@openpalm/lib', () => ({ seedUiBuild: vi.fn(() => Promise.resolve()), ensureHomeDirs: vi.fn(), checkAndUpdateUiBuild: vi.fn(() => Promise.resolve({ updated: false, latestVersion: '0.11.0' })), + checkAndUpdateClientBuild: vi.fn(() => Promise.resolve({ updated: false, latestVersion: '0.11.0' })), + checkAndUpdateSkeleton: vi.fn(() => Promise.resolve({ updated: false, latestVersion: '0.11.0' })), uiUpdateChannel: vi.fn((v: string) => (v.includes('-') ? 'next' : 'latest')), parseEnvFile: vi.fn(() => ({})), PLATFORM_VERSION: 'v0.11.0', + resolveClientAppPort: vi.fn(() => 3890), + resolveClientAppUrl: vi.fn(() => 'http://127.0.0.1:3890/chat'), + writeClientRuntimeConfig: vi.fn(), checkDocker: vi.fn(() => Promise.resolve({ ok: true, stdout: '', stderr: '', code: 0 })), checkDockerCompose: vi.fn(() => Promise.resolve({ ok: true, stdout: '', stderr: '', code: 0 })), // Faithful reimplementation of lib's waitForReady (poll /health; 200 or 401 == @@ -272,7 +277,7 @@ describe('buildUIServerEnv', () => { describe('harness contract', () => { it('is at the expected version', () => { - expect(HARNESS_CONTRACT_VERSION).toBe(1); + expect(HARNESS_CONTRACT_VERSION).toBe(2); expect(HARNESS_CONTRACT.version).toBe(HARNESS_CONTRACT_VERSION); }); @@ -282,6 +287,7 @@ describe('harness contract', () => { expect(HARNESS_CONTRACT.ipc.invoke).toEqual([ 'restart', 'restartUiServer', + 'openLocalApp', 'launchOnLoginStatus', 'setLaunchOnLogin', 'setTrayMicRecording', @@ -634,6 +640,21 @@ describe('Docker preflight', () => { }); }); +describe('localhost client app affordance', () => { + beforeEach(() => { + vi.mocked(shell.openExternal).mockClear(); + }); + + it('registers an open-local-app handler that opens the stable loopback client origin', async () => { + const handler = ipcMainHandleHandlers.get('open-local-app'); + expect(handler).toBeDefined(); + + await handler?.(); + + expect(shell.openExternal).toHaveBeenCalledWith('http://127.0.0.1:3890/chat'); + }); +}); + // ── before-quit handler (double-quit fix) ───────────────────────────────────── // The handler must be synchronous — Electron doesn't await async event handlers. // First call: preventDefault + cleanup + app.quit(). diff --git a/packages/electron/test/repo-hygiene.test.ts b/packages/electron/test/repo-hygiene.test.ts index e022a6cc5..f002af304 100644 --- a/packages/electron/test/repo-hygiene.test.ts +++ b/packages/electron/test/repo-hygiene.test.ts @@ -29,3 +29,16 @@ describe('packages/electron/dist is not git-tracked', () => { expect(tracked).toBe(''); }); }); + +describe('Electron client app artifact bootstrap', () => { + it('updates/seeds the client artifact before probing the resolved build dir', () => { + const mainSource = readFileSync(resolve(REPO_ROOT, 'packages/electron/src/main.ts'), 'utf-8'); + const updateIndex = mainSource.indexOf('await ensureClientAppBuild();'); + const startIndex = mainSource.indexOf('startClientAppServer();'); + + expect(mainSource).toContain('checkAndUpdateClientBuild'); + expect(mainSource).toContain('const buildDir = resolveClientBuildDir();'); + expect(updateIndex).toBeGreaterThanOrEqual(0); + expect(startIndex).toBeGreaterThan(updateIndex); + }); +}); diff --git a/packages/guardian/src/config.ts b/packages/guardian/src/config.ts index 01589ead1..8edfb218c 100644 --- a/packages/guardian/src/config.ts +++ b/packages/guardian/src/config.ts @@ -27,6 +27,41 @@ export const SESSION_TTL_MS = Number(Bun.env.GUARDIAN_SESSION_TTL_MS ?? 15 * 60_ /** Guardian direct-ingress port. Read once at module load. */ export const DIRECT_PORT = Number(Bun.env.GUARDIAN_DIRECT_PORT ?? 3830); +function normalizeExactOrigin(value: string): string { + if (value === '*') throw new Error('GUARDIAN_CORS_ALLOWED_ORIGINS must not contain wildcard origins'); + const url = new URL(value); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`GUARDIAN_CORS_ALLOWED_ORIGINS entry must use http or https: ${value}`); + } + if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) { + throw new Error(`GUARDIAN_CORS_ALLOWED_ORIGINS entry must be an exact origin with no path/query/hash: ${value}`); + } + return url.origin; +} + +function parseAllowedOrigins(value: string): ReadonlySet { + const origins = value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + .map(normalizeExactOrigin); + return new Set(origins); +} + +/** Exact browser origins allowed to receive CORS headers on direct ingress. */ +export const CORS_ALLOWED_ORIGINS = parseAllowedOrigins(Bun.env.GUARDIAN_CORS_ALLOWED_ORIGINS ?? ''); + +export function resolveCorsAllowedOrigin(origin: string | null): string | null { + if (!origin || origin === 'null') return null; + let normalized: string; + try { + normalized = normalizeExactOrigin(origin); + } catch { + return null; + } + return CORS_ALLOWED_ORIGINS.has(normalized) ? normalized : null; +} + /** * Guardian's own base URL as seen by API clients. Read lazily to preserve the * per-instance read semantics of `GuardianOpenAiApi.guardianUrl`. diff --git a/packages/guardian/src/cors.test.ts b/packages/guardian/src/cors.test.ts new file mode 100644 index 000000000..bcf227b24 --- /dev/null +++ b/packages/guardian/src/cors.test.ts @@ -0,0 +1,348 @@ +import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; +import type { Subprocess } from 'bun'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { OC_DOC_FIXTURE } from './oc-doc-fixture.ts'; + +const TEST_SECRET = 'cors-secret-1234'; +const TEST_PRINCIPAL = 'cors-direct'; +const TEST_ADMIN_TOKEN = 'cors-admin-token-1234'; +const ALLOWED_ORIGIN = 'https://app.openpalm.dev'; +const DENIED_ORIGIN = 'https://evil.example'; + +let guardianProc: Subprocess; +let mockAssistantServer: ReturnType; +let tmpDir: string; +let assistantPort = 0; +let internalPort = 0; +let directPort = 0; +let adminPort = 0; +let directUrl: string; +let adminUrl: string; + +function getAvailablePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + reject(new Error('Failed to resolve test port')); + return; + } + const { port } = address; + server.close((err) => (err ? reject(err) : resolve(port))); + }); + }); +} + +function authorization(secret = TEST_SECRET, principalId = TEST_PRINCIPAL): string { + return `Basic ${Buffer.from(`${principalId}:${secret}`, 'utf-8').toString('base64')}`; +} + +function directOcCall( + path: string, + init: RequestInit = {}, + opts: { origin?: string; userId?: string; withAuth?: boolean } = {}, +): Promise { + const headers = new Headers(init.headers); + if (opts.withAuth !== false) headers.set('authorization', authorization()); + if (opts.userId) headers.set('x-openpalm-user', opts.userId); + if (opts.origin) headers.set('origin', opts.origin); + return fetch(`${directUrl}/oc${path}`, { ...init, headers }); +} + +function adminRequest(origin?: string): Promise { + const headers = new Headers({ authorization: `Bearer ${TEST_ADMIN_TOKEN}` }); + if (origin) headers.set('origin', origin); + return fetch(`${adminUrl}/admin/principals`, { headers }); +} + +async function seedDirectPrincipal(): Promise { + const resp = await fetch(`${adminUrl}/admin/principals`, { + method: 'POST', + headers: { + authorization: `Bearer ${TEST_ADMIN_TOKEN}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + id: TEST_PRINCIPAL, + kind: 'direct', + token: TEST_SECRET, + label: 'CORS direct test client', + }), + }); + if (!resp.ok) throw new Error(`failed to seed direct principal: ${resp.status} ${await resp.text()}`); +} + +async function waitForGuardianReady(): Promise { + let ready = false; + for (let i = 0; i < 50; i++) { + if (guardianProc.exitCode !== null) { + throw new Error(`guardian exited before ready with code ${guardianProc.exitCode}`); + } + try { + const resp = await fetch(`${directUrl}/health`); + if (resp.ok) { + ready = true; + break; + } + } catch { + // not ready yet + } + await Bun.sleep(100); + } + if (!ready) throw new Error('guardian did not become ready'); + + for (let i = 0; i < 50; i++) { + const resp = await fetch(`http://127.0.0.1:${internalPort}/stats`, { + headers: { authorization: `Bearer ${TEST_ADMIN_TOKEN}` }, + }); + if (resp.ok && (await resp.json()).oc_proxy?.enabled === true) return; + await Bun.sleep(100); + } + throw new Error('guardian /oc proxy did not enable'); +} + +function startMockAssistant(): ReturnType { + return Bun.serve({ + port: assistantPort, + hostname: '127.0.0.1', + async fetch(req) { + const url = new URL(req.url); + if (url.pathname === '/doc' && req.method === 'GET') return Response.json(OC_DOC_FIXTURE); + if (url.pathname === '/session' && req.method === 'GET') return Response.json([]); + if (url.pathname === '/session' && req.method === 'POST') return Response.json({ id: 'cors-session-1' }); + if (url.pathname.startsWith('/session/') && req.method === 'GET') return Response.json({ id: 'cors-session-1' }); + if (url.pathname.startsWith('/session/') && url.pathname.endsWith('/message') && req.method === 'POST') { + return Response.json({ parts: [{ type: 'text', text: 'ok' }] }); + } + return new Response('not found', { status: 404 }); + }, + }); +} + +async function withDirectIngressDisabled(run: (directUrl: string) => Promise): Promise { + const disabledInternalPort = await getAvailablePort(); + const disabledDirectPort = await getAvailablePort(); + const disabledAdminPort = await getAvailablePort(); + const disabledProc = Bun.spawn(['bun', 'run', 'src/server.ts'], { + cwd: join(import.meta.dir, '..'), + env: { + ...process.env, + PORT: String(disabledInternalPort), + GUARDIAN_DIRECT_PORT: String(disabledDirectPort), + GUARDIAN_ADMIN_PORT: String(disabledAdminPort), + GUARDIAN_DIRECT_INGRESS: 'false', + GUARDIAN_CORS_ALLOWED_ORIGINS: ALLOWED_ORIGIN, + GUARDIAN_STATE_DB_PATH: join(tmpDir, `state-disabled-${disabledDirectPort}.db`), + GUARDIAN_ADMIN_TOKEN_FILE: join(tmpDir, 'admin-token'), + GUARDIAN_INTERNAL_HOST: '127.0.0.1', + OP_ASSISTANT_URL: `http://127.0.0.1:${assistantPort}`, + GUARDIAN_AUDIT_PATH: join(tmpDir, `audit-disabled-${disabledDirectPort}.log`), + }, + stdout: 'pipe', + stderr: 'pipe', + }); + const disabledUrl = `http://127.0.0.1:${disabledDirectPort}`; + try { + for (let i = 0; i < 50; i++) { + if (disabledProc.exitCode !== null) { + throw new Error(`guardian (direct disabled) exited before ready with code ${disabledProc.exitCode}`); + } + try { + const resp = await fetch(`${disabledUrl}/health`); + if (resp.ok) break; + } catch { + // not ready yet + } + await Bun.sleep(100); + } + return await run(disabledUrl); + } finally { + disabledProc.kill(); + } +} + +beforeAll(async () => { + assistantPort = await getAvailablePort(); + internalPort = await getAvailablePort(); + directPort = await getAvailablePort(); + adminPort = await getAvailablePort(); + + tmpDir = mkdtempSync(join(tmpdir(), 'guardian-cors-test-')); + const secretPath = join(tmpDir, 'test-secret'); + const adminTokenPath = join(tmpDir, 'admin-token'); + writeFileSync(secretPath, `${TEST_SECRET}\n`); + writeFileSync(adminTokenPath, `${TEST_ADMIN_TOKEN}\n`); + + mockAssistantServer = startMockAssistant(); + guardianProc = Bun.spawn(['bun', 'run', 'src/server.ts'], { + cwd: join(import.meta.dir, '..'), + env: { + ...process.env, + PORT: String(internalPort), + GUARDIAN_DIRECT_PORT: String(directPort), + GUARDIAN_ADMIN_PORT: String(adminPort), + GUARDIAN_DIRECT_INGRESS: 'true', + GUARDIAN_CORS_ALLOWED_ORIGINS: ALLOWED_ORIGIN, + GUARDIAN_STATE_DB_PATH: join(tmpDir, 'state.db'), + GUARDIAN_ADMIN_TOKEN_FILE: adminTokenPath, + GUARDIAN_INTERNAL_HOST: '127.0.0.1', + OP_ASSISTANT_URL: `http://127.0.0.1:${assistantPort}`, + GUARDIAN_AUDIT_PATH: join(tmpDir, 'audit.log'), + }, + stdout: 'pipe', + stderr: 'pipe', + }); + + directUrl = `http://127.0.0.1:${directPort}`; + adminUrl = `http://127.0.0.1:${adminPort}`; + await waitForGuardianReady(); + await seedDirectPrincipal(); +}); + +afterAll(() => { + guardianProc?.kill(); + mockAssistantServer?.stop(true); + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // best effort + } +}); + +describe('Guardian direct-ingress CORS', () => { + it('answers allowed-origin preflight requests on /oc routes', async () => { + const resp = await fetch(`${directUrl}/oc/session`, { + method: 'OPTIONS', + headers: { + origin: ALLOWED_ORIGIN, + 'access-control-request-method': 'POST', + 'access-control-request-headers': 'authorization, content-type, x-openpalm-user', + }, + }); + + expect(resp.status).toBe(204); + expect(resp.headers.get('access-control-allow-origin')).toBe(ALLOWED_ORIGIN); + expect(resp.headers.get('access-control-allow-credentials')).toBe('true'); + expect(resp.headers.get('access-control-allow-methods')).toContain('POST'); + expect(resp.headers.get('access-control-allow-headers')).toBe('authorization, content-type, x-openpalm-user'); + expect(resp.headers.get('vary')).toContain('Origin'); + expect(resp.headers.get('vary')).toContain('Access-Control-Request-Headers'); + }); + + it('adds CORS headers to allowed-origin success responses', async () => { + const resp = await directOcCall('/session', { method: 'GET' }, { origin: ALLOWED_ORIGIN, userId: 'allowed-user' }); + + expect(resp.status).toBe(200); + expect(resp.headers.get('access-control-allow-origin')).toBe(ALLOWED_ORIGIN); + expect(resp.headers.get('access-control-allow-credentials')).toBe('true'); + }); + + it('adds CORS headers to allowed-origin error responses', async () => { + const resp = await directOcCall('/session', { method: 'GET' }, { origin: ALLOWED_ORIGIN, withAuth: false }); + + expect(resp.status).toBe(401); + expect((await resp.json()).error).toBe('unauthorized'); + expect(resp.headers.get('access-control-allow-origin')).toBe(ALLOWED_ORIGIN); + expect(resp.headers.get('access-control-allow-credentials')).toBe('true'); + }); + + it('denies preflight for origins outside the allowlist', async () => { + const resp = await fetch(`${directUrl}/oc/session`, { + method: 'OPTIONS', + headers: { + origin: DENIED_ORIGIN, + 'access-control-request-method': 'POST', + 'access-control-request-headers': 'authorization, content-type, x-openpalm-user', + }, + }); + + expect(resp.status).toBe(403); + expect((await resp.json()).error).toBe('cors_origin_denied'); + expect(resp.headers.get('access-control-allow-origin')).toBeNull(); + expect(resp.headers.get('vary')).toContain('Origin'); + expect(resp.headers.get('vary')).toContain('Access-Control-Request-Headers'); + }); + + it('does not add CORS headers to denied-origin actual responses', async () => { + const resp = await directOcCall('/session', { method: 'GET' }, { origin: DENIED_ORIGIN, userId: 'denied-user' }); + + expect(resp.status).toBe(200); + expect(resp.headers.get('access-control-allow-origin')).toBeNull(); + expect(resp.headers.get('access-control-allow-credentials')).toBeNull(); + }); + + it('keeps allowed-origin /mcp disabled responses out of browser-side CORS failure', async () => { + const preflight = await fetch(`${directUrl}/mcp`, { + method: 'OPTIONS', + headers: { + origin: ALLOWED_ORIGIN, + 'access-control-request-method': 'POST', + 'access-control-request-headers': 'authorization, content-type', + }, + }); + + expect(preflight.status).toBe(204); + expect(preflight.headers.get('access-control-allow-origin')).toBe(ALLOWED_ORIGIN); + + const resp = await fetch(`${directUrl}/mcp`, { + method: 'POST', + headers: { + authorization: authorization(), + origin: ALLOWED_ORIGIN, + 'content-type': 'application/json', + }, + body: JSON.stringify({ jsonrpc: '2.0', id: '1', method: 'ping' }), + }); + + expect(resp.status).toBe(404); + expect((await resp.json()).error).toBe('not_found'); + expect(resp.headers.get('access-control-allow-origin')).toBe(ALLOWED_ORIGIN); + expect(resp.headers.get('access-control-allow-credentials')).toBe('true'); + }); + + it('keeps the admin listener out of CORS', async () => { + const resp = await adminRequest(ALLOWED_ORIGIN); + + expect(resp.status).toBe(200); + expect(resp.headers.get('access-control-allow-origin')).toBeNull(); + expect(resp.headers.get('access-control-allow-credentials')).toBeNull(); + }); + + it('does not treat non-browser OPTIONS without an Origin as CORS preflight', async () => { + const resp = await fetch(`${directUrl}/oc/session`, { + method: 'OPTIONS', + headers: { + authorization: authorization(), + }, + }); + + expect(resp.status).not.toBe(204); + expect(resp.headers.get('access-control-allow-origin')).toBeNull(); + }); + + it('does not answer browser preflight when direct ingress is disabled', async () => { + await withDirectIngressDisabled(async (disabledUrl) => { + const resp = await fetch(`${disabledUrl}/oc/session`, { + method: 'OPTIONS', + headers: { + origin: ALLOWED_ORIGIN, + 'access-control-request-method': 'POST', + 'access-control-request-headers': 'authorization, content-type, x-openpalm-user', + }, + }); + + expect(resp.status).toBe(404); + expect((await resp.json()).error).toBe('not_found'); + expect(resp.headers.get('access-control-allow-origin')).toBe(ALLOWED_ORIGIN); + expect(resp.headers.get('access-control-allow-credentials')).toBe('true'); + }); + }); +}); diff --git a/packages/guardian/src/proxy-moderation.test.ts b/packages/guardian/src/proxy-moderation.test.ts index 22987c9ec..ba8ea205e 100644 --- a/packages/guardian/src/proxy-moderation.test.ts +++ b/packages/guardian/src/proxy-moderation.test.ts @@ -251,8 +251,8 @@ describe("/oc proxy — content moderation (§3.5, write-path only, fail-closed) await Bun.sleep(50); } expect(line).toBeDefined(); - expect(line!.extra.error).toBe("content_blocked"); - expect(line!.extra.reason).toBeTruthy(); + expect(line?.extra.error).toBe("content_blocked"); + expect(line?.extra.reason).toBeTruthy(); }); test("malicious /prompt_async body → 403 content_blocked (fail-closed)", async () => { diff --git a/packages/guardian/src/server.ts b/packages/guardian/src/server.ts index bdbf7c995..73145cc07 100644 --- a/packages/guardian/src/server.ts +++ b/packages/guardian/src/server.ts @@ -23,7 +23,7 @@ import { activeRateLimiters, PORTAL_RATE_LIMIT, PORTAL_RATE_WINDOW_MS, USER_RATE import { runDriftCheckWithRetry, startProxyRecovery, stopProxyRecovery, isProxyEnabled } from './drift'; import { initializePrincipalStore, listPrincipals, seedPortalPrincipalsFromEnv } from './state-db'; import { matchTransport, registerTransport, type Transport } from './transport'; -import { DIRECT_PORT } from './config'; +import { DIRECT_PORT, resolveCorsAllowedOrigin } from './config'; const logger = createLogger('guardian'); @@ -46,11 +46,69 @@ const requestCounters = { byStatus: new Map(), }; +const CORS_ALLOW_METHODS = 'GET, POST, DELETE, PATCH, OPTIONS'; +const CORS_ALLOW_HEADERS = 'authorization, content-type, x-openpalm-user, x-openpalm-session-key, last-event-id'; + function countRequest(status: string) { requestCounters.total += 1; requestCounters.byStatus.set(status, (requestCounters.byStatus.get(status) ?? 0) + 1); } +function appendVary(headers: Headers, value: string): void { + const current = headers.get('vary'); + if (!current) { + headers.set('vary', value); + return; + } + const values = current.split(',').map((entry) => entry.trim().toLowerCase()); + if (!values.includes(value.toLowerCase())) headers.set('vary', `${current}, ${value}`); +} + +function isCorsPreflight(req: Request): boolean { + return req.method === 'OPTIONS' && req.headers.has('origin') && req.headers.has('access-control-request-method'); +} + +function isDirectBrowserSurface(url: URL): boolean { + return url.pathname === '/mcp' || url.pathname === OC_PREFIX || url.pathname.startsWith(`${OC_PREFIX}/`); +} + +function applyCorsHeaders(response: Response, origin: string | null): Response { + if (!origin) return response; + const headers = new Headers(response.headers); + headers.set('access-control-allow-origin', origin); + headers.set('access-control-allow-credentials', 'true'); + appendVary(headers, 'Origin'); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +function corsPreflightResponse(req: Request, requestId: string, origin: string | null): Response { + if (!origin) { + const response = json(403, { error: 'cors_origin_denied', requestId }); + const headers = new Headers(response.headers); + appendVary(headers, 'Origin'); + if (req.headers.has('access-control-request-headers')) appendVary(headers, 'Access-Control-Request-Headers'); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + } + const headers = new Headers({ + 'access-control-allow-origin': origin, + 'access-control-allow-credentials': 'true', + 'access-control-allow-methods': CORS_ALLOW_METHODS, + 'access-control-allow-headers': req.headers.get('access-control-request-headers')?.trim() || CORS_ALLOW_HEADERS, + 'access-control-max-age': '600', + }); + appendVary(headers, 'Origin'); + if (req.headers.has('access-control-request-headers')) appendVary(headers, 'Access-Control-Request-Headers'); + return new Response(null, { status: 204, headers }); +} + function statsResponse(): Response { const { activeUserLimiters, activePortalLimiters } = activeRateLimiters(); return json(200, { @@ -132,23 +190,30 @@ async function handleInternalRequest(req: Request, clientIp = ''): Promise { const url = new URL(req.url); const requestId = req.headers.get('x-request-id') ?? crypto.randomUUID(); + const corsOrigin = resolveCorsAllowedOrigin(req.headers.get('origin')); + const browserSurface = isDirectBrowserSurface(url); if (url.pathname === '/health' && req.method === 'GET') return handleHealth(requestId); - if (!DIRECT_INGRESS_ENABLED) return json(404, { error: 'not_found', requestId }); + if (!DIRECT_INGRESS_ENABLED) { + return browserSurface ? applyCorsHeaders(json(404, { error: 'not_found', requestId }), corsOrigin) : json(404, { error: 'not_found', requestId }); + } + if (browserSurface && isCorsPreflight(req)) return corsPreflightResponse(req, requestId, corsOrigin); + if (url.pathname === '/mcp') { - if (!MCP_ENABLED) return json(404, { error: 'not_found', requestId }); + if (!MCP_ENABLED) return applyCorsHeaders(json(404, { error: 'not_found', requestId }), corsOrigin); const response = await handleMcpRequest(req, requestId); countRequest(`mcp:${response.status}`); - return response; + return applyCorsHeaders(response, corsOrigin); } if (url.pathname === OC_PREFIX || url.pathname.startsWith(`${OC_PREFIX}/`)) { - return handleOcRequest(req, requestId, 'direct', clientIp); + const response = await handleOcRequest(req, requestId, 'direct', clientIp); + return applyCorsHeaders(response, corsOrigin); } const transport = matchTransport(url, req); if (transport) { const response = await transport.handle(req, requestId); countRequest(`${transport.name}:${response.status}`); - return response; + return applyCorsHeaders(response, corsOrigin); } return json(404, { error: 'not_found', requestId }); } diff --git a/packages/lib/src/control-plane/apply-stack-di.test.ts b/packages/lib/src/control-plane/apply-stack-di.test.ts index 30e834628..87df182d5 100644 --- a/packages/lib/src/control-plane/apply-stack-di.test.ts +++ b/packages/lib/src/control-plane/apply-stack-di.test.ts @@ -51,8 +51,6 @@ class FakeDocker implements DockerClient { } } -/** `compose ps --format json` row: running, no healthcheck ⇒ healthy. */ -const HEALTHY_PS_ROW = JSON.stringify({ Service: "assistant", State: "running", Health: "" }); /** Running but explicitly unhealthy. */ const UNHEALTHY_PS_ROW = JSON.stringify({ Service: "assistant", State: "running", Health: "unhealthy" }); diff --git a/packages/lib/src/control-plane/apply-validation.test.ts b/packages/lib/src/control-plane/apply-validation.test.ts index 74d3c9b1e..2040f5507 100644 --- a/packages/lib/src/control-plane/apply-validation.test.ts +++ b/packages/lib/src/control-plane/apply-validation.test.ts @@ -24,7 +24,11 @@ const SHIPPED_STACK_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '. const homes: string[] = []; afterEach(() => { - while (homes.length) rmSync(homes.pop()!, { recursive: true, force: true }); + let home = homes.pop(); + while (home) { + rmSync(home, { recursive: true, force: true }); + home = homes.pop(); + } }); /** A temp OP_HOME seeded with the shipped compose overlays + a valid stack env. */ diff --git a/packages/lib/src/control-plane/assistant-client-compose.test.ts b/packages/lib/src/control-plane/assistant-client-compose.test.ts new file mode 100644 index 000000000..f56e922ff --- /dev/null +++ b/packages/lib/src/control-plane/assistant-client-compose.test.ts @@ -0,0 +1,131 @@ +/** + * P5d (#510 Slice A) — compose publishes the client co-process port. + * + * RED tests written BEFORE the implementation (plan §6.9 known gap 1: "the + * co-process port is not published in any compose file"; Phase 5 item 4: + * "Publish the port in compose behind the existing bind-address policy"). + * + * All checks here are STATIC-ONLY: the managed compose YAML is parsed + * directly (same pattern as network-partitioning.test.ts) — no docker daemon + * exists in the dev/CI container, so `docker compose config` cannot be run. + * + * Tests marked CHARACTERIZATION pass against the current tree and pin landed + * behavior the P5d implementation must not regress. + */ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { parse as yamlParse } from 'yaml'; + +const REPO_ROOT = join(import.meta.dir, '../../../..'); +const STACK_DIR = join(REPO_ROOT, 'packages/skeleton/system/stack'); + +type ComposeService = { + ports?: unknown[]; + environment?: Record | string[]; + volumes?: unknown[]; +}; +type ComposeDoc = { services?: Record }; + +const core = yamlParse(readFileSync(join(STACK_DIR, 'core.compose.yml'), 'utf8')) as ComposeDoc; +const assistant = core.services?.assistant; +const assistantPorts: string[] = (assistant?.ports ?? []).map(String); +const clientPortEntries = assistantPorts.filter((p) => p.includes('OP_CLIENT_PORT')); + +/** True when the service environment (map or list form) defines `name`. */ +function environmentHas(service: ComposeService | undefined, name: string): boolean { + const env = service?.environment; + if (!env) return false; + if (Array.isArray(env)) return env.some((e) => String(e).startsWith(`${name}=`)); + return Object.keys(env).includes(name); +} + +describe('P5d compose — client port published on the assistant service', () => { + test('a client port mapping (OP_CLIENT_PORT) exists on the assistant service', () => { + expect(assistant, 'assistant service must exist in core.compose.yml').toBeTruthy(); + expect( + clientPortEntries.length, + `expected an OP_CLIENT_PORT mapping among assistant ports: ${JSON.stringify(assistantPorts)}`, + ).toBeGreaterThan(0); + }); + + test('client port host bind respects the OP_BIND_ADDRESS policy with a loopback default', () => { + // Accepts the global form `${OP_BIND_ADDRESS:-127.0.0.1}` or the nested + // per-service override form + // `${OP_CLIENT_BIND_ADDRESS:-${OP_BIND_ADDRESS:-127.0.0.1}}` (C1 pattern). + // Either way the default MUST be loopback and the global policy honored. + for (const entry of clientPortEntries) { + expect(entry).toContain('OP_BIND_ADDRESS:-127.0.0.1'); + } + expect(clientPortEntries.length).toBeGreaterThan(0); + }); + + test('client port maps to the in-container serve port (entrypoint default 3000)', () => { + // Container side must be the port start_client serves on: a literal 3000 + // or the OP_CLIENT_PORT variable defaulting to 3000. + const matching = clientPortEntries.filter((entry) => + /:(\$\{OP_CLIENT_PORT(:-3000)?\}|3000)$/.test(entry), + ); + expect( + matching.length, + `no client port entry targets container port 3000: ${JSON.stringify(clientPortEntries)}`, + ).toBeGreaterThan(0); + }); +}); + +describe('P5d compose — stack env plumbing for the exact-pin client artifact', () => { + test('OP_CLIENT_VERSION passes through to the assistant container (docker restart with a new pin picks up the new client)', () => { + // Plan §11 acceptance: `docker restart` with a new OP_CLIENT_VERSION must + // pick up the new artifact — so the env var has to reach the entrypoint + // through the assistant service environment, not just compose + // interpolation. + expect(environmentHas(assistant, 'OP_CLIENT_VERSION')).toBe(true); + }); + + test('OP_SKELETON_VERSION also passes through to the assistant container', () => { + // The assistant entrypoint installs both @openpalm/client and + // @openpalm/skeleton. A stack env override must reach the runtime + // environment or the container hard-fails before OpenCode starts. + expect(environmentHas(assistant, 'OP_SKELETON_VERSION')).toBe(true); + }); + + test('assistant receives host client ports for OpenCode CORS origin defaults', () => { + expect(environmentHas(assistant, 'OP_CLIENT_HOST_PORT')).toBe(true); + expect(environmentHas(assistant, 'OP_HOST_CLIENT_PORT')).toBe(true); + expect(environmentHas(assistant, 'OP_CLIENT_CORS_ALLOWED_ORIGINS')).toBe(true); + expect(environmentHas(assistant, 'OP_BIND_ADDRESS')).toBe(true); + expect(environmentHas(assistant, 'OP_ASSISTANT_BIND_ADDRESS')).toBe(true); + expect(environmentHas(assistant, 'OP_CLIENT_BIND_ADDRESS')).toBe(true); + }); + + // CHARACTERIZATION (green today): the client artifact installs under + // /opt/openpalm, which is the persistent assistant-artifacts named volume — + // warm restarts must keep reusing the installed client. + test('assistant-artifacts volume still persists /opt/openpalm (characterization)', () => { + const volumes = (assistant?.volumes ?? []).map(String); + expect(volumes).toContain('assistant-artifacts:/opt/openpalm'); + }); +}); + +describe('P5d docs — new env vars are documented (static-only)', () => { + test('OP_CLIENT_VERSION and OP_CLIENT_PORT appear in the stack env docs', () => { + const envAndMounts = readFileSync( + join(REPO_ROOT, 'docs/technical/environment-and-mounts.md'), + 'utf8', + ); + const consolidated = readFileSync( + join(REPO_ROOT, 'docs/technical/consolidated-stack-env.md'), + 'utf8', + ); + const combined = envAndMounts + consolidated; + // Boolean asserts keep the failure output readable (no full-doc dump). + expect( + combined.includes('OP_CLIENT_VERSION'), + 'neither environment-and-mounts.md nor consolidated-stack-env.md documents OP_CLIENT_VERSION', + ).toBe(true); + expect( + combined.includes('OP_CLIENT_PORT'), + 'neither environment-and-mounts.md nor consolidated-stack-env.md documents OP_CLIENT_PORT', + ).toBe(true); + }); +}); diff --git a/packages/lib/src/control-plane/assistant-client-entrypoint.test.ts b/packages/lib/src/control-plane/assistant-client-entrypoint.test.ts new file mode 100644 index 000000000..203f75781 --- /dev/null +++ b/packages/lib/src/control-plane/assistant-client-entrypoint.test.ts @@ -0,0 +1,292 @@ +/** + * P5d (#510 Slice A) — the assistant container serves `@openpalm/client` + * instead of the full `@openpalm/ui` host app (plan §6.9 as-landed + Phase 5 + * item 4; docs/technical/ui-runtime-modes-plan.md). + * + * RED tests written BEFORE the implementation. Two kinds of test live here: + * + * 1. BEHAVIORAL (bash-driven): `install_runtime_artifacts` is executed in a + * sandboxed bash with npm/bun/node stubbed onto PATH, so the exact-pin + * version-resolution chain (OP_CLIENT_VERSION → PLATFORM_VERSION → hard + * error, §8 rule 1: never `latest`) is exercised for real, not grepped. + * The entrypoint's trailing boot sequence (bare function-name lines at + * column 0) is stripped before sourcing so only definitions load. + * + * 2. STATIC-ONLY (labeled): string/regex assertions over entrypoint.sh and + * the Dockerfile. No docker daemon exists in the dev/CI container, so the + * co-process cannot be booted here — static verification (`bash -n` + + * content asserts) is the honest limit, mirroring + * assistant-rootless-entrypoint.test.ts. + * + * Tests marked CHARACTERIZATION pass against the current tree and pin + * landed behavior the P5d implementation must not regress. + */ +import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const REPO_ROOT = join(import.meta.dir, '../../../..'); +const ENTRYPOINT_PATH = join(REPO_ROOT, 'containers/assistant/entrypoint.sh'); +const entrypoint = readFileSync(ENTRYPOINT_PATH, 'utf8'); +const dockerfile = readFileSync(join(REPO_ROOT, 'containers/assistant/Dockerfile'), 'utf8'); + +// ── Behavioral harness ─────────────────────────────────────────────────────── +// Sources ONLY the function definitions from the entrypoint (top-level bare +// invocation lines — the boot sequence — are stripped; unindented bash +// keywords are preserved), stubs npm/bun/node to log-and-succeed (or fail on +// STUB_NPM_FAIL_PATTERN), then runs install_runtime_artifacts with a +// controlled environment. +const DRIVER = `#!/usr/bin/env bash +set -uo pipefail + +ENTRYPOINT="$1" +WORK="$2" + +mkdir -p "$WORK/bin" + +cat > "$WORK/bin/npm" <<'STUB' +#!/usr/bin/env bash +printf 'npm %s\\n' "$*" >> "$NPM_LOG" +prev="" +for arg in "$@"; do + if [ "$prev" = "--prefix" ] && [ ! -d "$arg" ]; then + echo "npm error missing prefix directory: $arg" >&2 + exit 13 + fi + prev="$arg" +done +if [ -n "\${STUB_NPM_FAIL_PATTERN:-}" ] && [[ "$*" == *"\${STUB_NPM_FAIL_PATTERN}"* ]]; then + echo "npm error simulated registry failure" >&2 + exit 1 +fi +exit 0 +STUB + +cat > "$WORK/bin/bun" <<'STUB' +#!/usr/bin/env bash +printf 'bun %s\\n' "$*" >> "$NPM_LOG" +if [ -n "\${STUB_NPM_FAIL_PATTERN:-}" ] && [[ "$*" == *"\${STUB_NPM_FAIL_PATTERN}"* ]]; then + echo "bun error simulated registry failure" >&2 + exit 1 +fi +exit 0 +STUB + +cat > "$WORK/bin/node" <<'STUB' +#!/usr/bin/env bash +printf 'node %s\\n' "$*" >> "$NPM_LOG" +exit 0 +STUB + +chmod +x "$WORK/bin/npm" "$WORK/bin/bun" "$WORK/bin/node" +export PATH="$WORK/bin:$PATH" + +# Strip the top-level boot-sequence invocations (bare function-name lines at +# column 0); keep unindented bash keywords so syntax cannot break. +awk '!/^[a-z_][a-z0-9_]*$/ || /^(fi|done|esac|then|else|do)$/' "$ENTRYPOINT" > "$WORK/functions.sh" +sed -i "s#/opt/openpalm#$WORK/artifacts#g" "$WORK/functions.sh" + +# shellcheck disable=SC1091 +source "$WORK/functions.sh" + +install_runtime_artifacts +`; + +type ScenarioResult = { exitCode: number; stderr: string; npmLog: string }; + +function runInstallScenario(scenarioEnv: Record): ScenarioResult { + const tempDir = mkdtempSync(join(tmpdir(), 'openpalm-p5d-entrypoint-')); + try { + const driverPath = join(tempDir, 'driver.sh'); + writeFileSync(driverPath, DRIVER, { mode: 0o755 }); + const npmLogPath = join(tempDir, 'npm.log'); + writeFileSync(npmLogPath, ''); + const home = join(tempDir, 'home'); + mkdirSync(home, { recursive: true }); + const proc = spawnSync('bash', [driverPath, ENTRYPOINT_PATH, tempDir], { + encoding: 'utf8', + // Deterministic env: only PATH/HOME plus the scenario's vars. No + // OP_*_VERSION or PLATFORM_VERSION leaks in from the outer process. + env: { + PATH: process.env.PATH ?? '/usr/bin:/bin', + HOME: home, + NPM_LOG: npmLogPath, + ...scenarioEnv, + }, + }); + return { + exitCode: proc.status ?? 1, + stderr: proc.stderr ?? '', + npmLog: readFileSync(npmLogPath, 'utf8'), + }; + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +function extractFunction(source: string, name: string): string | null { + const match = source.match(new RegExp(`^${name}\\(\\) \\{\\n[\\s\\S]*?^\\}`, 'm')); + return match ? match[0] : null; +} + +// ── Behavioral: exact-pin version resolution for @openpalm/client ─────────── + +describe('P5d install_runtime_artifacts — @openpalm/client version resolution (behavioral)', () => { + test('OP_CLIENT_VERSION override wins over PLATFORM_VERSION', () => { + const result = runInstallScenario({ + OP_CLIENT_VERSION: '9.9.9-test', + PLATFORM_VERSION: '1.1.1-platform', + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.npmLog).toContain('@openpalm/client@9.9.9-test'); + expect(result.npmLog).not.toContain('@openpalm/client@1.1.1-platform'); + }); + + test('falls back to PLATFORM_VERSION when OP_CLIENT_VERSION is unset', () => { + const result = runInstallScenario({ + PLATFORM_VERSION: '1.1.1-platform', + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.npmLog).toContain('@openpalm/client@1.1.1-platform'); + }); + + test('hard error naming OP_CLIENT_VERSION when neither version source is set (no latest fallback, §8 rule 1)', () => { + const result = runInstallScenario({}); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain('OP_CLIENT_VERSION'); + // Never silently resolve to a moving tag. + expect(result.npmLog).not.toContain('@latest'); + }); + + test('client install failure AFTER version resolution warns and continues (warm-restart resilience, plan §3)', () => { + const result = runInstallScenario({ + OP_CLIENT_VERSION: '9.9.9-test', + PLATFORM_VERSION: '1.1.1-platform', + STUB_NPM_FAIL_PATTERN: '@openpalm/client', + }); + // A registry blip must not brick a previously-working container. + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toMatch(/@openpalm\/client@9\.9\.9-test install failed/); + }); + + test('creates current artifact prefixes before npm install so old named volumes can upgrade', () => { + const result = runInstallScenario({ + OP_CLIENT_VERSION: '9.9.9-test', + OP_SKELETON_VERSION: '2.2.2-test', + PLATFORM_VERSION: '1.1.1-platform', + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.npmLog).toContain('--prefix'); + expect(result.stderr).not.toContain('missing prefix directory'); + }); + + // CHARACTERIZATION (green before AND after P5d): the skeleton pull is + // untouched by this phase. OP_UI_VERSION is supplied only so the pre-P5d + // entrypoint gets past its (to-be-removed) UI version gate; after P5d it is + // simply ignored. + test('skeleton resolution unchanged: OP_SKELETON_VERSION override still installs that exact pin (characterization)', () => { + const result = runInstallScenario({ + OP_CLIENT_VERSION: '1.1.1-platform', + OP_UI_VERSION: '1.1.1-platform', + OP_SKELETON_VERSION: '2.2.2-test', + PLATFORM_VERSION: '1.1.1-platform', + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.npmLog).toContain('@openpalm/skeleton@2.2.2-test'); + }); +}); + +// ── STATIC-ONLY checks (no docker daemon in this environment; content +// assertions + bash -n are the verification limit here) ────────────────── + +describe('P5d entrypoint — client co-process (static-only)', () => { + test('entrypoint installs @openpalm/client, with no @openpalm/ui remnants', () => { + expect(entrypoint).toContain('@openpalm/client'); + expect(entrypoint).not.toContain('@openpalm/ui'); + // Artifact prefix mirrors the landed layout under the persistent + // assistant-artifacts volume (/opt/openpalm). + expect(entrypoint).toContain('/opt/openpalm/client'); + expect(entrypoint).not.toContain('/opt/openpalm/ui'); + }); + + test('legacy OP_UI_VERSION resolution is replaced by OP_CLIENT_VERSION', () => { + expect(entrypoint).toContain('OP_CLIENT_VERSION'); + expect(entrypoint).not.toContain('OP_UI_VERSION'); + }); + + test('start_client replaces start_ui in both definition and boot sequence', () => { + expect(entrypoint).toMatch(/^start_client\(\) \{/m); + expect(entrypoint).not.toMatch(/^start_ui\(\) \{/m); + // Boot sequence: bare invocation at column 0. + expect(entrypoint).toMatch(/^start_client$/m); + expect(entrypoint).not.toMatch(/^start_ui$/m); + }); + + test('co-process launch exports no OPENCODE_API_URL (old wiring bug path deleted — browser talks to OpenCode directly)', () => { + // Scoped to the co-process function: the cron preamble legitimately + // forwards the compose-provided OPENCODE_API_URL to akm cron jobs and is + // NOT the co-process. + const coProcess = extractFunction(entrypoint, 'start_client') ?? extractFunction(entrypoint, 'start_ui'); + expect(coProcess, 'expected a start_client() co-process function in entrypoint.sh').toBeTruthy(); + expect(coProcess as string).not.toContain('OPENCODE_API_URL'); + // The static client is not an adapter-node server; no @openpalm/ui build entry. + expect(coProcess as string).not.toContain('build/index.js'); + }); + + test('client is served by the package static server on OP_CLIENT_PORT (default 3000), bound to 0.0.0.0 inside the container', () => { + const startClient = extractFunction(entrypoint, 'start_client') ?? ''; + expect(startClient, 'expected start_client() to be defined').not.toBe(''); + expect(startClient).toContain('serve.mjs'); + expect(startClient).toContain('OP_CLIENT_PORT:-3000'); + // In-container bind is 0.0.0.0; host exposure is governed by the compose + // port mapping (loopback default) — see assistant-client-compose.test.ts. + expect(startClient).toContain('0.0.0.0'); + }); + + test('runtime-config.json is written beside the build, pointing the BROWSER at the published OpenCode URL', () => { + expect(entrypoint).toContain('runtime-config.json'); + // Default: the host-published assistant port (what a browser can reach), + // NOT the in-container :4096. + expect(entrypoint).toContain('OP_ASSISTANT_PORT:-3800'); + // Operator override for non-default topologies. + expect(entrypoint).toContain('OP_CLIENT_DEFAULT_ASSISTANT_URL'); + }); + + test('OpenCode is launched with CORS for the shipped browser client origins', () => { + const startOpencode = extractFunction(entrypoint, 'start_opencode') ?? ''; + expect(startOpencode, 'expected start_opencode() to be defined').not.toBe(''); + expect(startOpencode).toContain('OP_CLIENT_HOST_PORT'); + expect(startOpencode).toContain('OP_HOST_CLIENT_PORT'); + expect(startOpencode).toContain('OP_CLIENT_CORS_ALLOWED_ORIGINS'); + expect(startOpencode).toContain('http://127.0.0.1:${client_host_port}'); + expect(startOpencode).toContain('http://127.0.0.1:${host_client_port}'); + expect(startOpencode).toContain('OP_CLIENT_BIND_ADDRESS'); + expect(startOpencode).toContain('OP_ASSISTANT_BIND_ADDRESS'); + expect(startOpencode).toContain('cors_origins+=("*")'); + expect(startOpencode).toContain('cmd+=(--cors "$origin")'); + }); + + // CHARACTERIZATION (green today): syntax gate. shellcheck is unavailable in + // this environment (documented limitation) — bash -n is the floor. + test('entrypoint stays bash -n clean (characterization)', () => { + const proc = spawnSync('bash', ['-n', ENTRYPOINT_PATH], { encoding: 'utf8' }); + expect(proc.status, proc.stderr).toBe(0); + }); +}); + +describe('P5d assistant Dockerfile (static-only)', () => { + test('no @openpalm/ui co-process remnants; client artifact dir seeded instead', () => { + expect(dockerfile).not.toContain('/opt/openpalm/ui'); + expect(dockerfile).not.toContain('OP_UI_VERSION'); + expect(dockerfile).toContain('/opt/openpalm/client'); + }); + + // CHARACTERIZATION (green today): PLATFORM_VERSION is already wired + // (build ARG re-exported as ENV) — the entrypoint fallback depends on it. + test('PLATFORM_VERSION build arg is re-exported for runtime resolution (characterization)', () => { + expect(dockerfile).toContain('ARG PLATFORM_VERSION'); + expect(dockerfile).toMatch(/PLATFORM_VERSION=\$\{PLATFORM_VERSION\}/); + }); +}); diff --git a/packages/lib/src/control-plane/assistant-rootless.test.ts b/packages/lib/src/control-plane/assistant-rootless.test.ts index 6524c84ab..091db98da 100644 --- a/packages/lib/src/control-plane/assistant-rootless.test.ts +++ b/packages/lib/src/control-plane/assistant-rootless.test.ts @@ -38,12 +38,15 @@ describe('assistant rootless conversion', () => { test('assistant image does not recursively chmod the baked tools tree (no duplicate giant layer)', () => { // /opt/openpalm/tools holds the multi-hundred-MB node_modules + model cache - // split across COPY layers; the seed-dir chmod must target the empty ui/skeleton - // dirs, never bare /opt/openpalm (which would re-materialize the whole tree). + // split across COPY layers; the seed-dir chmod must target the empty + // client/skeleton dirs, never bare /opt/openpalm (which would re-materialize + // the whole tree). (P5d, #510: the co-process artifact dir renamed from + // /opt/openpalm/ui to /opt/openpalm/client when @openpalm/client replaced + // @openpalm/ui in the assistant image — same seed-dir invariant.) const chmodLine = assistantDockerfile.split('\n').find((l) => l.includes('chmod -R a+rwX')); expect(chmodLine).toBeDefined(); expect(chmodLine).not.toMatch(/\/opt\/openpalm(\s|$)/); - expect(chmodLine).toContain('/opt/openpalm/ui'); + expect(chmodLine).toContain('/opt/openpalm/client'); expect(chmodLine).toContain('/opt/openpalm/skeleton'); }); }); diff --git a/packages/lib/src/control-plane/bind-warning.ts b/packages/lib/src/control-plane/bind-warning.ts index b02b72625..73ac6fb0b 100644 --- a/packages/lib/src/control-plane/bind-warning.ts +++ b/packages/lib/src/control-plane/bind-warning.ts @@ -13,6 +13,7 @@ /** Known per-service bind address env var names (mirrors compose files). */ const PER_SERVICE_BIND_VARS: readonly string[] = [ "OP_ASSISTANT_BIND_ADDRESS", + "OP_CLIENT_BIND_ADDRESS", "OP_CHAT_BIND_ADDRESS", "OP_API_BIND_ADDRESS", "OP_VOICE_BIND_ADDRESS", diff --git a/packages/lib/src/control-plane/client-app-url.test.ts b/packages/lib/src/control-plane/client-app-url.test.ts new file mode 100644 index 000000000..f3427aa3d --- /dev/null +++ b/packages/lib/src/control-plane/client-app-url.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'bun:test'; +import { DEFAULT_CLIENT_PORT, resolveClientAppPort, resolveClientAppUrl } from './client-app-url.ts'; + +describe('resolveClientAppPort', () => { + it('defaults to the stable localhost app port', () => { + expect(resolveClientAppPort({})).toBe(DEFAULT_CLIENT_PORT); + expect(resolveClientAppUrl({})).toBe('http://127.0.0.1:3890/chat'); + }); + + it('ignores the assistant container OP_CLIENT_PORT and only honors OP_HOST_CLIENT_PORT', () => { + const env = { + OP_CLIENT_PORT: '4810', + OP_HOST_CLIENT_PORT: '4890', + } satisfies NodeJS.ProcessEnv; + expect(resolveClientAppPort(env)).toBe(4890); + expect(resolveClientAppUrl(env)).toBe('http://127.0.0.1:4890/chat'); + }); + + it('keeps the stable localhost app origin when only OP_CLIENT_PORT is set', () => { + expect(resolveClientAppPort({ OP_CLIENT_PORT: '4810' })).toBe(DEFAULT_CLIENT_PORT); + expect(resolveClientAppUrl({ OP_CLIENT_PORT: '4810' })).toBe('http://127.0.0.1:3890/chat'); + }); +}); diff --git a/packages/lib/src/control-plane/client-app-url.ts b/packages/lib/src/control-plane/client-app-url.ts new file mode 100644 index 000000000..4f1c117a8 --- /dev/null +++ b/packages/lib/src/control-plane/client-app-url.ts @@ -0,0 +1,9 @@ +export const DEFAULT_CLIENT_PORT = 3890; + +export function resolveClientAppPort(env: NodeJS.ProcessEnv = process.env): number { + return Number(env.OP_HOST_CLIENT_PORT) || DEFAULT_CLIENT_PORT; +} + +export function resolveClientAppUrl(env: NodeJS.ProcessEnv = process.env): string { + return `http://127.0.0.1:${resolveClientAppPort(env)}/chat`; +} diff --git a/packages/lib/src/control-plane/client-assets.test.ts b/packages/lib/src/control-plane/client-assets.test.ts new file mode 100644 index 000000000..ab96be163 --- /dev/null +++ b/packages/lib/src/control-plane/client-assets.test.ts @@ -0,0 +1,413 @@ +// ── P5c RED TESTS (#555) — client-build resolution + seeding ───────────────── +// +// These tests pin the lib surface for serving `@openpalm/client` from the +// harness (plan Phase 5 item 3; phase-5-completion-guide §4 P5c item 1): +// a THIN SIBLING of ui-assets.ts that reuses npm-bundle-updater (never forks it). +// +// Pinned design decisions (the tests are the contract): +// • Module: `client-assets.ts` next to `ui-assets.ts` (same barrel re-export +// pattern; these imports fail until the module exists — that is the red). +// • On-disk layout: the client artifact keeps the npm package's root shape — +// `/build/` (static files; gate file `index.html`) and +// `/bin/serve.mjs` (the zero-dependency static server the CLI spawns). +// Channels: +// data channel → OP_HOME/data/client/{build,bin} +// dev override → $OPENPALM_REPO_ROOT/packages/client/{build,bin} +// This is forced by "bin/serve.mjs from the resolved client build": the +// serve script must travel WITH the updatable artifact so a compiled CLI +// binary can run it in every channel, and `join(buildDir, '..', 'bin', +// 'serve.mjs')` holds in both channels. +// • resolveClientBuildDir() returns the BUILD dir (…/client/build), with the +// same version-aware two-channel selection as resolveUiBuildDir (data wins +// only when strictly newer per the stamp). +// • Version stamp: `.openpalm-client-version` INSIDE the build dir — written +// by packages/client/scripts/stamp-version.mjs, so the constant here is a +// cross-package contract. +// • seedClientBuild(repoRef, dataDir, options?) — local-or-download like +// seedUiBuild, but with NO harness-contract gate: the client is a static +// bundle with no native bridge to outgrow. +// • checkAndUpdateClientBuild(appVersion, dataDir, channelOverride?) — +// channel/verify/stage/swap/backup pipeline via checkAndUpdateNpmBundle, +// integrity fail-closed, never crosses a major, non-fatal errors. +// +// Test patterns follow ui-assets.test.ts (env pinning, makeBuild, mocked fetch, +// real tarball for the happy path). +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, cpSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { createHash } from "node:crypto"; +import { fileURLToPath } from 'node:url'; +import { + CLIENT_VERSION_STAMP, + readClientBuildVersion, + resolveClientBuildDir, + seedClientBuild, + checkAndUpdateClientBuild, +} from "./client-assets.js"; + +let root = ""; +let opHome = ""; +let repoRoot = ""; +let dataDir = ""; +/** Data-channel build dir: OP_HOME/data/client/build */ +let dataClient = ""; +/** Data-channel package root: OP_HOME/data/client */ +let dataClientRoot = ""; +/** Dev-override build dir: $OPENPALM_REPO_ROOT/packages/client/build */ +let bundledClient = ""; +const saved: Record = {}; + +function walk(dir: string): string[] { + return (existsSync(dir) ? readdirSync(dir, { withFileTypes: true }) : []) + .flatMap((entry) => { + const path = join(dir, entry.name); + return entry.isDirectory() ? walk(path) : [path]; + }); +} + +/** Materialize a static client build: index.html + optional version stamp. */ +function makeBuild(dir: string, version: string | null): void { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "index.html"), "client\n"); + if (version !== null) writeFileSync(join(dir, ".openpalm-client-version"), `${version}\n`); +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "client-assets-")); + opHome = join(root, "ophome"); + repoRoot = join(root, "repo"); + dataDir = join(opHome, "data"); + dataClientRoot = join(dataDir, "client"); + dataClient = join(dataClientRoot, "build"); + bundledClient = join(repoRoot, "packages", "client", "build"); + saved.OP_HOME = process.env.OP_HOME; + saved.OPENPALM_REPO_ROOT = process.env.OPENPALM_REPO_ROOT; + saved.OP_UI_CHANNEL = process.env.OP_UI_CHANNEL; + delete process.env.OP_UI_CHANNEL; + process.env.OP_HOME = opHome; + // Pin the bundled candidate to a controlled location so the resolver never + // discovers the REAL packages/client/build via a source-relative fallback + // (same pinning trick as ui-assets.test.ts). Default: an EMPTY build dir + // (exists but no index.html) = "no bundled build". + process.env.OPENPALM_REPO_ROOT = repoRoot; + mkdirSync(bundledClient, { recursive: true }); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); + for (const k of ["OP_HOME", "OPENPALM_REPO_ROOT", "OP_UI_CHANNEL"] as const) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } +}); + +// ── Version stamp ───────────────────────────────────────────────────────────── + +describe("CLIENT_VERSION_STAMP / readClientBuildVersion", () => { + it("CLIENT_VERSION_STAMP matches the filename packages/client/scripts/stamp-version.mjs writes", () => { + // Cross-package contract: the client build script stamps + // build/.openpalm-client-version; lib must read the SAME filename. + expect(CLIENT_VERSION_STAMP).toBe(".openpalm-client-version"); + }); + + it("reads the stamp, or null when absent", () => { + makeBuild(dataClient, "0.12.0"); + expect(readClientBuildVersion(dataClient)).toBe("0.12.0"); + makeBuild(bundledClient, null); + expect(readClientBuildVersion(bundledClient)).toBeNull(); + }); +}); + +// ── resolveClientBuildDir — version-aware selection (mirrors resolveUiBuildDir) ─ + +describe("resolveClientBuildDir — version-aware selection", () => { + it("uses data/client/build when only it exists", () => { + makeBuild(dataClient, "0.12.0"); + expect(resolveClientBuildDir()).toBe(dataClient); + }); + + it("uses the bundled (OPENPALM_REPO_ROOT) build when only it exists", () => { + makeBuild(bundledClient, "0.12.0"); + expect(resolveClientBuildDir()).toBe(bundledClient); + }); + + it("prefers data/client/build only when it is strictly NEWER than bundled", () => { + makeBuild(dataClient, "0.13.0"); + makeBuild(bundledClient, "0.12.0"); + expect(resolveClientBuildDir()).toBe(dataClient); + }); + + it("prefers bundled when it is newer than data (no stale-data shadowing)", () => { + makeBuild(dataClient, "0.12.0"); + makeBuild(bundledClient, "0.13.0"); + expect(resolveClientBuildDir()).toBe(bundledClient); + }); + + it("prefers bundled when versions are equal", () => { + makeBuild(dataClient, "0.12.0"); + makeBuild(bundledClient, "0.12.0"); + expect(resolveClientBuildDir()).toBe(bundledClient); + }); + + it("prefers bundled when data is unstamped (cannot prove it is newer)", () => { + makeBuild(dataClient, null); + makeBuild(bundledClient, "0.12.0"); + expect(resolveClientBuildDir()).toBe(bundledClient); + }); + + it("falls back to the data path when nothing is present (caller seeds)", () => { + expect(resolveClientBuildDir()).toBe(dataClient); + }); +}); + +// ── seedClientBuild ─────────────────────────────────────────────────────────── + +describe("seedClientBuild", () => { + it("seeds BOTH build/ and bin/serve.mjs from a local checkout into data/client", async () => { + // Local dev-override source: build + the sibling serve script. The serve + // script must travel with the seeded artifact — the CLI runs + // data/client/bin/serve.mjs, and a compiled binary has no other copy. + makeBuild(bundledClient, "0.12.52"); + const bundledBin = join(repoRoot, "packages", "client", "bin"); + mkdirSync(bundledBin, { recursive: true }); + writeFileSync(join(bundledBin, "serve.mjs"), "// client static server\n"); + + await seedClientBuild("v0.12.52", dataDir); + + expect(existsSync(join(dataClient, "index.html"))).toBe(true); + expect(existsSync(join(dataClientRoot, "bin", "serve.mjs"))).toBe(true); + expect(readClientBuildVersion(dataClient)).toBe("0.12.52"); + }); + + it("remote seed targets @openpalm/client on npm and fails CLOSED on a missing integrity hash", async () => { + const requested: string[] = []; + const savedFetch = globalThis.fetch; + globalThis.fetch = (async (_url: string | URL | Request) => { + const url = String(typeof _url === "string" ? _url : (_url as Request).url ?? _url); + if (url.includes("registry.npmjs.org")) { + requested.push(url); + return new Response( + JSON.stringify({ + version: "0.13.0", + // integrity intentionally omitted → the download must refuse + dist: { tarball: "https://registry.npmjs.org/client.tgz" }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response("not-reached", { status: 200 }); + }) as typeof globalThis.fetch; + try { + await expect( + seedClientBuild("latest", dataDir, { forceRemote: true }), + ).rejects.toThrow(/no integrity hash/i); + // Pins the package coordinate: the manifest fetch is for @openpalm/client. + expect(requested.some((u) => u.includes("/@openpalm/client/"))).toBe(true); + } finally { + globalThis.fetch = savedFetch; + } + }); +}); + +// ── checkAndUpdateClientBuild ───────────────────────────────────────────────── + +describe("checkAndUpdateClientBuild", () => { + let savedFetch: typeof globalThis.fetch; + + beforeEach(() => { + savedFetch = globalThis.fetch; + mkdirSync(join(dataDir, "backups"), { recursive: true }); + }); + + afterEach(() => { + globalThis.fetch = savedFetch; + }); + + function manifestResponse(version: string, integrity?: string) { + return new Response( + JSON.stringify({ + version, + dist: { + tarball: "https://registry.npmjs.org/client.tgz", + ...(integrity !== undefined ? { integrity } : {}), + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + + it("returns {updated:false} when the npm channel version is not newer than the on-disk stamp", async () => { + makeBuild(dataClient, "0.12.0"); + globalThis.fetch = (async () => manifestResponse("0.12.0")) as typeof globalThis.fetch; + const result = await checkAndUpdateClientBuild("0.12.0", dataDir); + expect(result.updated).toBe(false); + expect(result.latestVersion).toBe("0.12.0"); + expect(result.error).toBeUndefined(); + }); + + it("returns {updated:false} when npm has an older version", async () => { + makeBuild(dataClient, "0.13.0"); + globalThis.fetch = (async () => manifestResponse("0.12.0")) as typeof globalThis.fetch; + const result = await checkAndUpdateClientBuild("0.13.0", dataDir); + expect(result.updated).toBe(false); + expect(result.latestVersion).toBe("0.12.0"); + }); + + it("returns {updated:false, error} when the manifest fetch rejects (non-fatal)", async () => { + globalThis.fetch = (async () => { throw new Error("network failure"); }) as typeof globalThis.fetch; + const result = await checkAndUpdateClientBuild("0.12.0", dataDir); + expect(result.updated).toBe(false); + expect(result.latestVersion).toBeNull(); + expect(result.error).toMatch(/network failure/i); + }); + + it("fails closed when the manifest has no integrity hash", async () => { + makeBuild(dataClient, "0.12.0"); + globalThis.fetch = (async (_url: string | URL | Request) => { + const url = String(typeof _url === "string" ? _url : (_url as Request).url ?? _url); + if (url.includes("registry.npmjs.org")) return manifestResponse("0.13.0"); // no integrity + return new Response("", { status: 200 }); + }) as typeof globalThis.fetch; + const result = await checkAndUpdateClientBuild("0.12.0", dataDir); + expect(result.updated).toBe(false); + expect(result.error).toMatch(/no integrity hash/i); + }); + + it("never auto-crosses a major version boundary", async () => { + makeBuild(dataClient, "0.12.0"); + let tarballFetched = false; + globalThis.fetch = (async (_url: string | URL | Request) => { + const url = String(typeof _url === "string" ? _url : (_url as Request).url ?? _url); + if (url.includes("registry.npmjs.org")) return manifestResponse("1.0.0", "sha512-abc"); + tarballFetched = true; + return new Response("", { status: 200 }); + }) as typeof globalThis.fetch; + const result = await checkAndUpdateClientBuild("0.12.0", dataDir); + expect(result.updated).toBe(false); + expect(result.latestVersion).toBe("1.0.0"); + expect(result.error).toBeUndefined(); + expect(tarballFetched).toBe(false); + }); + + it("stable → @latest dist-tag; prerelease → newest version across ALL dist-tags (not the stale `next` tag)", async () => { + makeBuild(dataClient, "0.12.0"); + const refs: string[] = []; + globalThis.fetch = (async (_url: string | URL | Request) => { + const url = String(typeof _url === "string" ? _url : (_url as Request).url ?? _url); + if (url.includes("/dist-tags")) { + // `next` deliberately stale — the shared resolver must pick max(dist-tags). + return new Response( + JSON.stringify({ next: "0.12.0-rc.1", beta: "0.12.5-beta.2", latest: "0.12.4" }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url.includes("/@openpalm/client/")) { + refs.push(url.split("/@openpalm/client/")[1] ?? ""); + return manifestResponse("0.12.0"); // up to date → no download attempted + } + return new Response("", { status: 200 }); + }) as typeof globalThis.fetch; + await checkAndUpdateClientBuild("0.12.4", dataDir); // stable → latest + await checkAndUpdateClientBuild("0.12.0-rc.1", dataDir); // prerelease → next channel + expect(refs[0]).toBe("latest"); + expect(refs[1]).toBe("0.12.5-beta.2"); + }); + + it("happy path: verifies integrity, swaps build/ + bin/, keeps the shipped stamp, and backs up the old artifact", async () => { + // Plant the OLD artifact in the data channel (build + serve script). + makeBuild(dataClient, "0.12.0"); + writeFileSync(join(dataClient, "index.html"), "\n"); + mkdirSync(join(dataClientRoot, "bin"), { recursive: true }); + writeFileSync(join(dataClientRoot, "bin", "serve.mjs"), "// old serve\n"); + + // Build a minimal valid @openpalm/client tarball (npm wraps under package/; + // `files: ["build", "bin"]` publishes exactly these two trees, and the build + // script stamps build/.openpalm-client-version before publish). + const pkgRoot = mkdtempSync(join(tmpdir(), "client-happy-pkg-")); + mkdirSync(join(pkgRoot, "package", "build"), { recursive: true }); + mkdirSync(join(pkgRoot, "package", "bin"), { recursive: true }); + writeFileSync(join(pkgRoot, "package", "build", "index.html"), "\n"); + writeFileSync(join(pkgRoot, "package", "build", ".openpalm-client-version"), "0.13.0\n"); + writeFileSync(join(pkgRoot, "package", "bin", "serve.mjs"), "// new serve\n"); + const tarPath = join(pkgRoot, "client.tgz"); + const { exited } = Bun.spawn(["tar", "-czf", tarPath, "-C", pkgRoot, "package"], { stdout: "pipe", stderr: "pipe" }); + await exited; + const tarBytes = new Uint8Array(await Bun.file(tarPath).arrayBuffer()); + const integrity = `sha512-${createHash("sha512").update(tarBytes).digest("base64")}`; + rmSync(pkgRoot, { recursive: true, force: true }); + + globalThis.fetch = (async (_url: string | URL | Request) => { + const url = String(typeof _url === "string" ? _url : (_url as Request).url ?? _url); + if (url.includes("registry.npmjs.org")) { + return new Response( + JSON.stringify({ version: "0.13.0", dist: { tarball: "https://r.npm/client.tgz", integrity } }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response(tarBytes, { status: 200 }); + }) as typeof globalThis.fetch; + + const result = await checkAndUpdateClientBuild("0.12.0", dataDir); + + expect(result.updated).toBe(true); + expect(result.latestVersion).toBe("0.13.0"); + expect(result.error).toBeUndefined(); + // New static build + serve script are in place; the shipped stamp reads back. + expect(readFileSync(join(dataClient, "index.html"), "utf8")).toContain("new"); + expect(existsSync(join(dataClientRoot, "bin", "serve.mjs"))).toBe(true); + expect(readClientBuildVersion(dataClient)).toBe("0.13.0"); + // The OLD artifact was backed up under data/backups/client-/ (same + // pattern as the UI/skeleton backups) so a supervisor can restore it if the + // new build fails to serve. + const backupsDir = join(dataDir, "backups"); + const clientBackups = existsSync(backupsDir) + ? (await import("node:fs")).readdirSync(backupsDir).filter((n: string) => n.startsWith("client-")) + : []; + expect(clientBackups.length).toBeGreaterThan(0); + const backup = join(backupsDir, clientBackups[0] ?? ""); + // Tolerant to whether the backup captured the package root or the build dir. + const backedUpIndex = [join(backup, "build", "index.html"), join(backup, "index.html")] + .find((p) => existsSync(p)); + expect(backedUpIndex, "backup must contain the old index.html").toBeDefined(); + if (backedUpIndex) expect(readFileSync(backedUpIndex, "utf8")).toContain("old"); + }); + + it("container-pulled artifact path preserves client purity markers after the npm swap", async () => { + const repoClientBuild = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'client', 'build'); + expect(existsSync(join(repoClientBuild, 'index.html'))).toBe(true); + + const pkgRoot = mkdtempSync(join(tmpdir(), 'client-purity-pkg-')); + mkdirSync(join(pkgRoot, 'package', 'build'), { recursive: true }); + mkdirSync(join(pkgRoot, 'package', 'bin'), { recursive: true }); + cpSync(repoClientBuild, join(pkgRoot, 'package', 'build'), { recursive: true }); + writeFileSync(join(pkgRoot, 'package', 'bin', 'serve.mjs'), '// new serve\n'); + const tarPath = join(pkgRoot, 'client.tgz'); + const { exited } = Bun.spawn(['tar', '-czf', tarPath, '-C', pkgRoot, 'package'], { stdout: 'pipe', stderr: 'pipe' }); + await exited; + const tarBytes = new Uint8Array(await Bun.file(tarPath).arrayBuffer()); + const integrity = `sha512-${createHash('sha512').update(tarBytes).digest('base64')}`; + rmSync(pkgRoot, { recursive: true, force: true }); + + globalThis.fetch = (async (_url: string | URL | Request) => { + const url = String(typeof _url === 'string' ? _url : (_url as Request).url ?? _url); + if (url.includes('registry.npmjs.org')) { + return new Response( + JSON.stringify({ version: '0.13.0', dist: { tarball: 'https://r.npm/client.tgz', integrity } }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + return new Response(tarBytes, { status: 200 }); + }) as typeof globalThis.fetch; + + const result = await checkAndUpdateClientBuild('0.12.0', dataDir); + + expect(result.updated).toBe(true); + const installedFiles = walk(dataClient); + const forbiddenMarkers = ['@openpalm/lib', '/api/host']; + for (const marker of forbiddenMarkers) { + const offenders = installedFiles.filter((file) => readFileSync(file).toString('latin1').includes(marker)); + expect(offenders, `installed artifact must not contain ${marker}`).toEqual([]); + } + }); +}); diff --git a/packages/lib/src/control-plane/client-assets.ts b/packages/lib/src/control-plane/client-assets.ts new file mode 100644 index 000000000..05b4e05ba --- /dev/null +++ b/packages/lib/src/control-plane/client-assets.ts @@ -0,0 +1,290 @@ +/** + * Runtime asset seeding and resolution for the `@openpalm/client` static app — + * a THIN SIBLING of ui-assets.ts (plan ui-runtime-modes-plan.md Phase 5 item 3, + * #555). Same channel/verify/stage/swap/backup pipeline via npm-bundle-updater; + * only the policy knobs differ. + * + * The client artifact keeps the npm package's root shape on disk: + * + * /build/ adapter-static bundle (gate file: index.html; the + * build script stamps build/.openpalm-client-version) + * /bin/serve.mjs the zero-dependency static server the harness spawns + * + * Channels: + * data channel → OP_HOME/data/client/{build,bin} (operator-updatable) + * dev override → $OPENPALM_REPO_ROOT/packages/client/{build,bin} + * + * The serve script travels WITH the updatable artifact so a compiled CLI binary + * can run it in every channel: `join(buildDir, '..', 'bin', 'serve.mjs')` holds + * for both. + * + * Unlike the UI there is NO harness-contract gate: the client is a static + * bundle served over plain HTTP — it has no native bridge to outgrow (§8.10: + * it never bundles @openpalm/lib and never holds host credentials). + * + * Node.js-compatible only (no Bun.* APIs) — consumed by the CLI and Electron. + */ +import { existsSync, readFileSync, realpathSync, renameSync, rmSync, mkdirSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { resolveDataDir } from './home.js'; +import { createLogger } from '../logger.js'; +import { compareComparableVersions, normalizeVersion } from './versioning.js'; +import { + NPM_REGISTRY, + fetchWithRetry, + stageNpmBundle, + checkAndUpdateNpmBundle, + type NpmBundleManifest, +} from './npm-bundle-updater.js'; +import { copyTree, resolveChannelRef, uiUpdateChannel, type UiUpdateChannel } from './ui-assets.js'; + +const logger = createLogger('lib:client-assets'); + +const CLIENT_PACKAGE = '@openpalm/client'; + +/** + * Filename of the build-time version stamp written into the client build root + * by packages/client/scripts/stamp-version.mjs — a cross-package contract: the + * stamp travels with the static bundle wherever it is copied/extracted. + */ +export const CLIENT_VERSION_STAMP = '.openpalm-client-version'; + +/** Read the stamped client version from a build dir, or null if absent/unreadable. */ +export function readClientBuildVersion(dir: string): string | null { + try { + const v = readFileSync(join(dir, CLIENT_VERSION_STAMP), 'utf-8').trim(); + return v || null; + } catch { + return null; + } +} + +/** The data-channel package root (OP_HOME/data/client — holds build/ + bin/). */ +function dataClientRoot(): string { + return join(resolveDataDir(), 'client'); +} + +/** + * Locate a bundled/local client build on disk (mirrors resolveLocalUiBuild). + * Returns null when not found — triggers the npm download in seedClientBuild. + */ +export function resolveLocalClientBuild(): string | null { + const strategies: Array<() => string | null> = [ + // 1. Explicit dev override — OPENPALM_REPO_ROOT points to the repo root. + () => process.env.OPENPALM_REPO_ROOT + ? join(process.env.OPENPALM_REPO_ROOT, 'packages', 'client', 'build') + : null, + // 2. Electron extraResources — client-build/ placed alongside the asar. + () => { + const rp = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; + if (!rp) return null; + return join(rp, 'client-build'); + }, + // 3. Relative to this source file (dev / bun run). + () => { + const meta = fileURLToPath(import.meta.url); + if (meta.startsWith('/$bunfs/')) return null; + const candidate = join(dirname(meta), '..', '..', '..', '..', 'packages', 'client', 'build'); + return existsSync(join(candidate, 'index.html')) ? candidate : null; + }, + // 4. Relative to compiled binary / Electron executable. + () => { + const binDir = dirname(realpathSync(process.execPath)); + const candidate = join(binDir, '..', '..', '..', 'packages', 'client', 'build'); + return existsSync(join(candidate, 'index.html')) ? candidate : null; + }, + ]; + for (const strategy of strategies) { + try { + const p = strategy(); + if (p && existsSync(p)) return p; + } catch { /* skip */ } + } + return null; +} + +/** + * Resolve which client build to serve — the BUILD dir (…/client/build), with + * the same version-aware two-channel selection as resolveUiBuildDir: when both + * channels hold a build, data/client wins ONLY when it is strictly newer per + * the version stamp; an unstamped/older data/client never shadows a newer + * bundled build. Falls back to the data path when nothing is present (the + * caller seeds). + */ +export function resolveClientBuildDir(): string { + const dataBuild = join(dataClientRoot(), 'build'); + const hasData = existsSync(join(dataBuild, 'index.html')); + // resolveLocalClientBuild()'s env/resourcesPath candidates only check the dir + // exists, not that it holds a servable build — require index.html before + // trusting it (same gate as resolveUiBuildDir). + const bundledRaw = resolveLocalClientBuild(); + const bundled = bundledRaw && existsSync(join(bundledRaw, 'index.html')) ? bundledRaw : null; + + if (hasData && bundled) { + const dataVer = readClientBuildVersion(dataBuild); + const bundledVer = readClientBuildVersion(bundled); + // data/client wins only when we can prove it's strictly newer. + if (dataVer && bundledVer && compareComparableVersions(dataVer, bundledVer) > 0) return dataBuild; + // De-routed installs must be VISIBLE, never silent (same rationale as + // resolveUiBuildDir, §6.1 Risk #1). + if (!dataVer) { + logger.warn('data/client present but UNSTAMPED — ignoring it and serving the bundled client build', { + dataBuild, bundled, bundledVersion: bundledVer ?? '(unstamped)', + }); + } else { + logger.warn('data/client present but not strictly newer than the bundled build — serving the bundled client build', { + dataBuild, dataVersion: dataVer, bundled, bundledVersion: bundledVer ?? '(unstamped)', + }); + } + return bundled; + } + if (hasData) return dataBuild; + if (bundled) return bundled; + return dataBuild; // nothing present yet → caller triggers seedClientBuild +} + +/** + * Resolve the abbreviated npm manifest for `@openpalm/client` by exact version + * OR dist-tag. Throws on non-OK. No minHarnessContract field — the client has + * no native bridge (contrast fetchNpmUiManifest). + */ +async function fetchNpmClientManifest(versionOrTag: string): Promise { + const url = `${NPM_REGISTRY}/${CLIENT_PACKAGE}/${versionOrTag}`; + const res = await fetchWithRetry(url); + if (!res.ok) throw new Error(`npm registry returned HTTP ${res.status} for ${CLIENT_PACKAGE}@${versionOrTag}`); + const m = await res.json() as { version?: string; dist?: { tarball?: string; integrity?: string } }; + if (!m.version || !m.dist?.tarball) { + throw new Error(`npm manifest for ${CLIENT_PACKAGE}@${versionOrTag} is missing version/dist.tarball`); + } + return { version: m.version, tarball: m.dist.tarball, integrity: m.dist.integrity ?? null }; +} + +/** + * Download `@openpalm/client`'s npm tarball, verify integrity (fail-closed via + * stageNpmBundle), and swap BOTH published trees — `build/` and `bin/` — into + * the client package root. npm wraps the package under `package/` and we + * publish `files: ["build", "bin"]`, so strip 1 component and keep exactly + * those two subtrees. The shipped stamp (build/.openpalm-client-version) is + * kept as-is — no afterInstall re-stamp needed (same as the UI build). + */ +async function downloadNpmClientBundle(manifest: NpmBundleManifest, clientRoot: string, dataDir: string): Promise { + const staging = await stageNpmBundle(manifest, dataDir, { + packageName: CLIENT_PACKAGE, + label: 'client', + tmpTarName: '.client.tgz.tmp', + stagingName: '.client.staging', + strip: 1, + filter: (p) => p.startsWith('package/build/') || p.startsWith('package/bin/'), + validate: (s) => { + if (!existsSync(join(s, 'build', 'index.html'))) { + throw new Error('downloaded client bundle is missing build/index.html'); + } + if (!existsSync(join(s, 'bin', 'serve.mjs'))) { + throw new Error('downloaded client bundle is missing bin/serve.mjs'); + } + }, + }); + try { + // Swap: the staged root IS the artifact — remove the live root and move it in. + rmSync(clientRoot, { recursive: true, force: true }); + renameSync(staging, clientRoot); + } finally { + rmSync(staging, { recursive: true, force: true }); + } +} + +/** + * Install the client artifact to OP_HOME/data/client/ ({build,bin}). + * + * Copies from the local/bundled `packages/client/` when available — BOTH the + * build and the sibling bin/serve.mjs, because the CLI runs + * data/client/bin/serve.mjs and a compiled binary has no other copy — otherwise + * downloads the `@openpalm/client` bundle from npm (integrity fail-closed). + * No harness-contract gate: the client is a static bundle (see module doc). + */ +export async function seedClientBuild( + repoRef: string, + dataDir: string, + options?: { forceRemote?: boolean }, +): Promise { + const clientRoot = join(dataDir, 'client'); + + const local = options?.forceRemote ? null : resolveLocalClientBuild(); + if (local) { + logger.debug('seeding client build from local source', { src: local }); + mkdirSync(clientRoot, { recursive: true }); + copyTree(local, join(clientRoot, 'build')); + // The serve script lives beside the build in the source/package layout + // (packages/client/bin/serve.mjs) — it must travel with the seeded artifact. + copyTree(join(local, '..', 'bin'), join(clientRoot, 'bin')); + if (!readClientBuildVersion(join(clientRoot, 'build'))) { + logger.warn('seeded client build has no version stamp — auto-update comparison will be unreliable', { src: local }); + } + return; + } + + // normalizeVersion strips a leading 'v' so a release ref (v1.2.3) becomes the + // npm version (1.2.3); dist-tags (latest/next) pass through unchanged. + const manifest = await fetchNpmClientManifest(normalizeVersion(repoRef)); + logger.debug('downloading client build from npm', { version: manifest.version }); + await downloadNpmClientBundle(manifest, clientRoot, dataDir); +} + +// ── Client update check ────────────────────────────────────────────────────── + +export interface ClientBuildUpdateResult { + updated: boolean; + latestVersion: string | null; + error?: string; + /** + * The on-disk backup of the PREVIOUS client artifact (data/backups/client-, + * capturing the package root: build/ + bin/). Present only when `updated` is + * true and a prior artifact existed — kept so a supervisor can restore it if + * the new build fails to serve. + */ + backupDir?: string; +} + +/** + * Check npm for a newer `@openpalm/client` build and apply it if one exists. + * + * `@openpalm/client` is independently versioned (like `@openpalm/ui`): pick the + * dist-tag CHANNEL from the app's release stream (prerelease → newest across + * all dist-tags, stable → `latest`) and compare against the version stamped in + * the RESOLVED build on disk. Never auto-crosses a major version. + * + * When an update is available: + * 1. Move data/client/ → data/backups/client-{timestamp}/ (build + bin) + * 2. Download the npm bundle (integrity fail-closed) and swap it in + * + * Non-fatal: any network or extraction error returns { updated: false, error }. + * On a failed install the backup is restored in place (restoreOnFailure) — + * unlike the UI there is no supervisor restore hook wired for the client, so + * the artifact must never be left missing. + */ +export async function checkAndUpdateClientBuild( + appVersion: string, + dataDir: string, + channelOverride?: UiUpdateChannel, +): Promise { + const clientRoot = join(dataDir, 'client'); + return checkAndUpdateNpmBundle({ + appVersion, + logLabel: 'client build', + resolveManifest: async () => + fetchNpmClientManifest(await resolveChannelRef(CLIENT_PACKAGE, uiUpdateChannel(appVersion, channelOverride))), + // Compare against the client build currently on disk, NOT the app version — + // the client floats on its own version line. The app version is only the + // fallback major-version guard when the on-disk build is unstamped. + readCurrentVersion: () => readClientBuildVersion(resolveClientBuildDir()), + backup: { dir: clientRoot, gate: join(clientRoot, 'build', 'index.html'), prefix: 'client' }, + install: (manifest) => downloadNpmClientBundle(manifest, clientRoot, dataDir), + restoreOnFailure: true, + onBlockedMajor: (latestVersion) => ({ updated: false, latestVersion }), + onUpToDate: (latestVersion) => ({ updated: false, latestVersion }), + onSuccess: (latestVersion, backupDir) => ({ updated: true, latestVersion, backupDir }), + onError: (error, backupDir) => ({ updated: false, latestVersion: null, error, backupDir }), + }); +} diff --git a/packages/lib/src/control-plane/client-runtime-config.test.ts b/packages/lib/src/control-plane/client-runtime-config.test.ts new file mode 100644 index 000000000..3d374134b --- /dev/null +++ b/packages/lib/src/control-plane/client-runtime-config.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'bun:test'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { buildLockedAssistantRuntimeConfig, writeClientRuntimeConfig } from './client-runtime-config.js'; + +describe('client runtime config', () => { + test('builds one locked default local-opencode connection', () => { + expect(buildLockedAssistantRuntimeConfig('http://127.0.0.1:3800')).toEqual({ + connections: [ + { + id: 'openpalm-assistant-opencode', + label: 'This assistant', + kind: 'local-opencode', + url: 'http://127.0.0.1:3800', + auth: { mode: 'none' }, + isDefault: true, + locked: true, + }, + ], + }); + }); + + test('writes the runtime config JSON file', () => { + const dir = mkdtempSync(join(tmpdir(), 'client-runtime-config-')); + try { + const path = join(dir, 'nested', 'runtime-config.json'); + writeClientRuntimeConfig(path, 'https://assistant.example'); + expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual( + buildLockedAssistantRuntimeConfig('https://assistant.example') + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/lib/src/control-plane/client-runtime-config.ts b/packages/lib/src/control-plane/client-runtime-config.ts new file mode 100644 index 000000000..ec307c1d6 --- /dev/null +++ b/packages/lib/src/control-plane/client-runtime-config.ts @@ -0,0 +1,37 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; + +export type ClientRuntimeConnection = { + id: string; + label: string; + kind: 'local-opencode'; + url: string; + auth: { mode: 'none' }; + isDefault: true; + locked: true; +}; + +export type ClientRuntimeConfig = { + connections: ClientRuntimeConnection[]; +}; + +export function buildLockedAssistantRuntimeConfig(url: string): ClientRuntimeConfig { + return { + connections: [ + { + id: 'openpalm-assistant-opencode', + label: 'This assistant', + kind: 'local-opencode', + url, + auth: { mode: 'none' }, + isDefault: true, + locked: true, + }, + ], + }; +} + +export function writeClientRuntimeConfig(path: string, assistantUrl: string): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(buildLockedAssistantRuntimeConfig(assistantUrl), null, 2)}\n`); +} diff --git a/packages/lib/src/control-plane/config-persistence.ts b/packages/lib/src/control-plane/config-persistence.ts index 729221516..aee4f566c 100644 --- a/packages/lib/src/control-plane/config-persistence.ts +++ b/packages/lib/src/control-plane/config-persistence.ts @@ -383,7 +383,7 @@ export function discoverHomeBindMountSources( // volume`) carry a volume name, not a path. if (vol.type && vol.type !== 'bind') continue; const source = vol.source; - if (!source || !source.startsWith('/')) continue; + if (!source?.startsWith('/')) continue; const resolvedHostPath = resolvePath(source); if (!resolvedHostPath.startsWith(`${homeRoot}/`) && resolvedHostPath !== homeRoot) continue; diff --git a/packages/lib/src/control-plane/guardian-rootless-entrypoint.test.ts b/packages/lib/src/control-plane/guardian-rootless-entrypoint.test.ts index 6fe31d94b..33b675a31 100644 --- a/packages/lib/src/control-plane/guardian-rootless-entrypoint.test.ts +++ b/packages/lib/src/control-plane/guardian-rootless-entrypoint.test.ts @@ -35,13 +35,15 @@ describe('guardian rootless entrypoint regressions', () => { /install_artifact "\$OP_GUARDIAN_PACKAGE" "\$VERSION" (\S+)/, ); expect(installMatch).not.toBeNull(); - const installPrefix = installMatch![1]; + if (!installMatch) throw new Error('guardian entrypoint install prefix not found'); + const installPrefix = installMatch[1]; const bakeMatch = guardianDockerfile.match( - /\(cd (\S+) && bun add "@openpalm\/guardian@\$\{GUARDIAN_VERSION\}"/, + /\(cd (\S+) && bun add "\$guardian_spec"/, ); expect(bakeMatch).not.toBeNull(); - const bakePrefix = bakeMatch![1]; + if (!bakeMatch) throw new Error('guardian Dockerfile bake prefix not found'); + const bakePrefix = bakeMatch[1]; // Both build-time bake and boot-time install/skip-check must agree on // the same prefix, or the already-at-version check in install_artifact() @@ -53,7 +55,8 @@ describe('guardian rootless entrypoint regressions', () => { // top-level `secrets:`/`volumes:` key). const guardianServiceMatch = portalsCompose.match(/\n {2}guardian:\n([\s\S]*?)\n(?=\S)/); expect(guardianServiceMatch).not.toBeNull(); - const guardianServiceBlock = guardianServiceMatch![1]; + if (!guardianServiceMatch) throw new Error('guardian service block not found'); + const guardianServiceBlock = guardianServiceMatch[1]; const mountTargets = [...guardianServiceBlock.matchAll(/- \S+:(\/opt\/openpalm\S*)/g)].map( (m) => m[1], ); diff --git a/packages/lib/src/control-plane/guardian-rootless.test.ts b/packages/lib/src/control-plane/guardian-rootless.test.ts index 628292704..037608d1a 100644 --- a/packages/lib/src/control-plane/guardian-rootless.test.ts +++ b/packages/lib/src/control-plane/guardian-rootless.test.ts @@ -29,7 +29,7 @@ describe('guardian rootless conversion', () => { // g=u would only grant write when OP_GID == 1000; world-writable is required so // the arbitrary uid can `bun add` guardian/skeleton into /opt/openpalm on first // boot. Secrets live in a separate 0600 bind-mount tree. - expect(guardianDockerfile).toContain('chmod -R a+rwX /opt/openpalm/guardian /opt/openpalm/guardian-pkg /opt/openpalm/skeleton /opt/openpalm/tools'); + expect(guardianDockerfile).toContain('chmod -R a+rwX /opt/openpalm /opt/openpalm/guardian /opt/openpalm/guardian-pkg /opt/openpalm/skeleton /opt/openpalm/tools'); expect(guardianDockerfile).not.toContain('chmod -R g=u'); }); }); diff --git a/packages/lib/src/control-plane/moderation-doc-contract.test.ts b/packages/lib/src/control-plane/moderation-doc-contract.test.ts index a3ecfb2dd..676613771 100644 --- a/packages/lib/src/control-plane/moderation-doc-contract.test.ts +++ b/packages/lib/src/control-plane/moderation-doc-contract.test.ts @@ -52,7 +52,8 @@ describe("guardian moderation.md doc contract (fable 1.2)", () => { test("core-principles.md's config/ guardian/ subtree bullet does not claim to hold moderation.md", () => { const configSectionMatch = doc.match(/### 1\) Config[\s\S]*?### 1b\)/); expect(configSectionMatch).not.toBeNull(); - const configSection = configSectionMatch![0]; + if (!configSectionMatch) throw new Error('Config section not found'); + const configSection = configSectionMatch[0]; const guardianBullet = configSection.split("\n").find((line) => line.startsWith("- `guardian/`")); expect(guardianBullet).toBeDefined(); expect(guardianBullet).not.toContain("instructions/moderation.md"); diff --git a/packages/lib/src/control-plane/portal-secret-contract.test.ts b/packages/lib/src/control-plane/portal-secret-contract.test.ts index e10783f74..c5d806e5b 100644 --- a/packages/lib/src/control-plane/portal-secret-contract.test.ts +++ b/packages/lib/src/control-plane/portal-secret-contract.test.ts @@ -113,7 +113,7 @@ describe("portal verification-secret contract (compose ↔ portalSecretName ↔ }); it("declares op_api_key at the top level, file-backed under knowledge/secrets/", () => { - const decl = topLevelSecrets["op_api_key"]; + const decl = topLevelSecrets.op_api_key; expect(decl, "top-level secrets: must declare op_api_key").toBeDefined(); expect(basename(decl?.file ?? "")).toBe("op_api_key"); expect(decl?.file).toContain("/knowledge/secrets/"); diff --git a/packages/lib/src/control-plane/rootless-guardrail-script.test.ts b/packages/lib/src/control-plane/rootless-guardrail-script.test.ts index 0827475e2..c802d6cf7 100644 --- a/packages/lib/src/control-plane/rootless-guardrail-script.test.ts +++ b/packages/lib/src/control-plane/rootless-guardrail-script.test.ts @@ -69,4 +69,15 @@ describe('rootless phase-0 script guardrails', () => { expect(fixtureHelper).toContain('smoke_write_stack_env'); expect(fixtureHelper).toContain('smoke_ensure_home_dirs'); }); + + test('rootless smoke fixtures assign an isolated assistant-container client port', () => { + expect(fixtureHelper).toContain('OP_CLIENT_PORT=${client_port}'); + expect(smokeScript).toContain('OP_ROOTLESS_SMOKE_CLIENT_PORT'); + expect(hostSwapSmokeScript).toContain('3994'); + }); + + test('rootless smokes only run compose down when the prior stack env still exists', () => { + expect(smokeScript).toContain('if [[ -f "$SMOKE_HOME/knowledge/env/stack.env" ]]'); + expect(hostSwapSmokeScript).toContain('if [[ -f "$SWAP_HOME/knowledge/env/stack.env" ]]'); + }); }); diff --git a/packages/lib/src/control-plane/ui-assets.ts b/packages/lib/src/control-plane/ui-assets.ts index 122289e0a..e3fd51d8a 100644 --- a/packages/lib/src/control-plane/ui-assets.ts +++ b/packages/lib/src/control-plane/ui-assets.ts @@ -41,9 +41,11 @@ import { const logger = createLogger('lib:ui-assets'); -// ── Private helpers ────────────────────────────────────────────────────────── +// ── Shared helpers ─────────────────────────────────────────────────────────── +// Exported for the sibling asset modules (client-assets.ts) — not part of the +// package barrel. -function copyTree( +export function copyTree( src: string, dest: string, opts?: { skipExisting?: boolean }, @@ -387,7 +389,7 @@ async function fetchNpmUiManifest(versionOrTag: string): Promise * resolver (which lists tags and picks the newest on-channel), take max(dist-tags) * = the true bleeding edge (latest beta/rc/stable, whichever is newest). */ -async function resolveChannelRef(pkg: string, channel: UiUpdateChannel): Promise { +export async function resolveChannelRef(pkg: string, channel: UiUpdateChannel): Promise { if (channel === 'latest') return 'latest'; const res = await fetchWithRetry(`${NPM_REGISTRY}/-/package/${encodeURIComponent(pkg)}/dist-tags`); if (!res.ok) throw new Error(`npm registry returned HTTP ${res.status} for ${pkg} dist-tags`); diff --git a/packages/lib/src/control-plane/volume-ownership.test.ts b/packages/lib/src/control-plane/volume-ownership.test.ts index d2eb01be8..b613f79bd 100644 --- a/packages/lib/src/control-plane/volume-ownership.test.ts +++ b/packages/lib/src/control-plane/volume-ownership.test.ts @@ -13,10 +13,9 @@ * in a fresh subprocess. */ import { describe, test, expect } from 'bun:test'; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { join } from 'node:path'; -import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; const dockerUrl = new URL('./docker.js', import.meta.url).href; diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index 653148b86..dc6c029e0 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -544,6 +544,28 @@ export { declaredUiChannel, } from "./control-plane/ui-assets.js"; +// ── Client app (static SPA) asset seeding and resolution (#555 P5c) ───────── +export type { ClientBuildUpdateResult } from "./control-plane/client-assets.js"; +export { + CLIENT_VERSION_STAMP, + readClientBuildVersion, + resolveLocalClientBuild, + resolveClientBuildDir, + seedClientBuild, + checkAndUpdateClientBuild, +} from "./control-plane/client-assets.js"; +export { + DEFAULT_CLIENT_PORT, + resolveClientAppPort, + resolveClientAppUrl, +} from './control-plane/client-app-url.js'; +export { + buildLockedAssistantRuntimeConfig, + writeClientRuntimeConfig, + type ClientRuntimeConfig, + type ClientRuntimeConnection, +} from './control-plane/client-runtime-config.js'; + // ── UI-server supervisor primitives (shared by CLI + Electron) ─────────────── export type { WaitForReadyDeps, diff --git a/packages/skeleton/system/stack/README.md b/packages/skeleton/system/stack/README.md index 3b8e54085..017cbeb75 100644 --- a/packages/skeleton/system/stack/README.md +++ b/packages/skeleton/system/stack/README.md @@ -1,42 +1,47 @@ -# config/stack/ +# system/stack/ -This directory contains the runtime stack composition and configuration. OpenPalm runs from the fixed compose file set: `core.compose.yml`, `services.compose.yml`, `portals.compose.yml`, and `custom.compose.yml`. +This directory contains the managed runtime stack composition. OpenPalm runs +from the fixed managed file set here plus the user-owned overlay at +`$OP_HOME/config/stack/custom.compose.yml`. ## Quick start ```bash # Run the core stack by hand -cd ~/.openpalm/config/stack +OP_HOME="${OP_HOME:-$HOME/.openpalm}" docker compose \ --project-name openpalm \ - --env-file ../../knowledge/env/stack.env \ - -f core.compose.yml \ - -f services.compose.yml \ - -f portals.compose.yml \ - -f custom.compose.yml \ + --env-file "$OP_HOME/knowledge/env/stack.env" \ + -f "$OP_HOME/system/stack/core.compose.yml" \ + -f "$OP_HOME/system/stack/services.compose.yml" \ + -f "$OP_HOME/system/stack/portals.compose.yml" \ + -f "$OP_HOME/config/stack/custom.compose.yml" \ up -d # Enable built-in optional services with profiles docker compose \ --project-name openpalm \ - --env-file ../../knowledge/env/stack.env \ - -f core.compose.yml \ - -f services.compose.yml \ - -f portals.compose.yml \ - -f custom.compose.yml \ + --env-file "$OP_HOME/knowledge/env/stack.env" \ + -f "$OP_HOME/system/stack/core.compose.yml" \ + -f "$OP_HOME/system/stack/services.compose.yml" \ + -f "$OP_HOME/system/stack/portals.compose.yml" \ + -f "$OP_HOME/config/stack/custom.compose.yml" \ --profile addon.chat \ up -d ``` -See the [Manual Compose Runbook](../../docs/operations/manual-compose-runbook.md) for preflight, +See the [Manual Compose Runbook](../../../../docs/operations/manual-compose-runbook.md) for preflight, status, logs, and all other operations. -## Core services +## Common services -| Service | Host port | Purpose | -|---------|-----------|---------| -| `assistant` | `3800 -> 4096` | OpenCode runtime without Docker socket; also hosts the automation scheduler co-process (no port) | -| `guardian` | `3830 -> 3830` and `3831 -> 3831` (localhost by default) | Principal-authenticated ingress, direct listener, and admin listener | +The assistant is the only always-on core container. The guardian is enabled by +portal-style addon profiles such as `addon.chat` or `addon.api`. + +| Service | Activation | Host port | Purpose | +|---------|------------|-----------|---------| +| `assistant` | Always on | `3800 -> 4096` | OpenCode runtime without Docker socket; also hosts the automation scheduler co-process (no port) | +| `guardian` | Portal/addon profiles | `3830 -> 3830` and `3831 -> 3831` (localhost by default) | Principal-authenticated ingress, direct listener, and admin listener | ## Addons @@ -69,14 +74,15 @@ overlays. | `core.compose.yml` | Core service definition (always used) | System (managed via CLI/admin) | | `services.compose.yml` | Optional first-party services | System (managed via CLI/admin) | | `portals.compose.yml` | Optional first-party portals | System (managed via CLI/admin) | -| `custom.compose.yml` | User custom services and overlays | User | +| `$OP_HOME/config/stack/custom.compose.yml` | User custom services and overlays | User | -This directory holds compose assembly only — **no secrets and no env files**. +This directory holds managed compose assembly only — **no secrets, no env files, +and no user-owned overlays**. ## Env files Compose receives **only one env file**, from outside this directory: -- `../../knowledge/env/stack.env` (akm `env:stack`) — Non-secret runtime configuration only +- `$OP_HOME/knowledge/env/stack.env` (akm `env:stack`) — Non-secret runtime configuration only Secrets live in `knowledge/secrets/` (including OpenCode `auth.json`) and are granted to services through Compose `secrets:` entries or direct bind mounts. Do diff --git a/packages/skeleton/system/stack/core.compose.yml b/packages/skeleton/system/stack/core.compose.yml index 89dee9c24..550c890c9 100644 --- a/packages/skeleton/system/stack/core.compose.yml +++ b/packages/skeleton/system/stack/core.compose.yml @@ -16,9 +16,10 @@ # (bind-mounted into the assistant + guardian). # # Directory model: -# ~/.openpalm/config/ — user-editable + system config (akm/) -# ~/.openpalm/config/stack/ — compose assembly only (core/services/portals/custom -# compose files) — no secrets, no env +# ~/.openpalm/system/ — managed release assets, including system/stack/ +# core/services/portals compose files +# ~/.openpalm/config/ — user-editable config, including only the +# config/stack/custom.compose.yml stack overlay # ~/.openpalm/data/ — persistent service data, logs, backups, rollback # ~/.openpalm/knowledge/ — akm knowledge (skills, env, secrets, agents); # env:user, env:stack, and auth.json live here @@ -63,12 +64,42 @@ services: OP_UID: ${OP_UID:-1000} OP_GID: ${OP_GID:-1000} OPENCODE_API_URL: http://localhost:4096 + # @openpalm/client co-process (P5d, #510). OP_CLIENT_VERSION is the + # exact-pin override for the client artifact the entrypoint installs; + # empty falls back to the image's baked PLATFORM_VERSION. It must reach + # the container ENVIRONMENT (not just compose interpolation) so a + # restart with a new pin picks up the new client. + OP_CLIENT_VERSION: ${OP_CLIENT_VERSION:-} + # The assistant entrypoint installs @openpalm/skeleton alongside the + # client artifact using the same exact-pin override/fallback chain. + OP_SKELETON_VERSION: ${OP_SKELETON_VERSION:-} + # The entrypoint writes the client's runtime-config.json pointing the + # BROWSER at the host-published OpenCode URL — that is the assistant + # port mapping below, NOT the in-container :4096 (unreachable from a + # browser). OP_CLIENT_DEFAULT_ASSISTANT_URL overrides the full URL for + # non-default topologies (e.g. a reverse proxy in front of the stack). + OP_ASSISTANT_PORT: ${OP_ASSISTANT_PORT:-3800} + OP_CLIENT_DEFAULT_ASSISTANT_URL: ${OP_CLIENT_DEFAULT_ASSISTANT_URL:-} + # Browser clients are separate origins from OpenCode. These host-side + # ports are passed only so the entrypoint can add exact CORS origins when + # launching OpenCode; OP_CLIENT_PORT inside the container remains the + # static client's in-container listen port default (3000). + OP_CLIENT_HOST_PORT: ${OP_CLIENT_PORT:-3810} + OP_HOST_CLIENT_PORT: ${OP_HOST_CLIENT_PORT:-3890} + OP_CLIENT_CORS_ALLOWED_ORIGINS: ${OP_CLIENT_CORS_ALLOWED_ORIGINS:-} + OP_BIND_ADDRESS: ${OP_BIND_ADDRESS:-127.0.0.1} + OP_ASSISTANT_BIND_ADDRESS: ${OP_ASSISTANT_BIND_ADDRESS:-${OP_BIND_ADDRESS:-127.0.0.1}} + OP_CLIENT_BIND_ADDRESS: ${OP_CLIENT_BIND_ADDRESS:-${OP_BIND_ADDRESS:-127.0.0.1}} # Provider credentials live in OpenCode's auth.json (bind-mounted # below) — NOT in this env block. The Connections tab and the setup # wizard both call OpenCode's PUT /auth/{providerID}; ai-sdk picks # the key up via OpenCode at call time. ports: - "${OP_ASSISTANT_BIND_ADDRESS:-127.0.0.1}:${OP_ASSISTANT_PORT:-3800}:4096" + # Chat client (@openpalm/client static co-process, in-container port + # 3000). Loopback by default; honors the global OP_BIND_ADDRESS policy + # with a per-service override, same as the addon listeners. + - "${OP_CLIENT_BIND_ADDRESS:-${OP_BIND_ADDRESS:-127.0.0.1}}:${OP_CLIENT_PORT:-3810}:3000" volumes: # data/assistant is HOME (runtime). config/assistant is the power-user's # OpenCode GLOBAL config, mounted at ~/.config/opencode (nested over HOME). @@ -96,8 +127,8 @@ services: # under $HOME. Prefer $HOME/.local when possible; use /opt/persistent for # tools that require an explicit non-home prefix. - assistant-persistent:/opt/persistent - # Platform artifact cache — persists @openpalm/ui and @openpalm/skeleton - # across restarts so warm starts don't re-download. + # Platform artifact cache — persists @openpalm/client and + # @openpalm/skeleton across restarts so warm starts don't re-download. - assistant-artifacts:/opt/openpalm # Tool packages managed via package.json. Edit OP_HOME/data/assistant/tools/package.json # to pin or update individual tool versions; bun update applies changes on next start. @@ -138,6 +169,6 @@ networks: volumes: assistant-persistent: - # Named volume for @openpalm/ui and @openpalm/skeleton artifacts. + # Named volume for @openpalm/client and @openpalm/skeleton artifacts. # The tools subpath (/opt/openpalm/tools) is overlaid by the bind-mount above. assistant-artifacts: diff --git a/packages/skeleton/system/stack/portals.compose.yml b/packages/skeleton/system/stack/portals.compose.yml index b75d99773..c707675ad 100644 --- a/packages/skeleton/system/stack/portals.compose.yml +++ b/packages/skeleton/system/stack/portals.compose.yml @@ -112,6 +112,7 @@ services: GUARDIAN_MODERATION_THRESHOLD: ${GUARDIAN_MODERATION_THRESHOLD:-3} GUARDIAN_AUDIT_PATH: /opt/openpalm/logs/guardian-audit.log GUARDIAN_DIRECT_INGRESS: ${GUARDIAN_DIRECT_INGRESS:-false} + GUARDIAN_CORS_ALLOWED_ORIGINS: ${GUARDIAN_CORS_ALLOWED_ORIGINS:-} GUARDIAN_MCP: ${GUARDIAN_MCP:-false} GUARDIAN_ADMIN_TOKEN_FILE: /run/secrets/op_guardian_admin_token GUARDIAN_MCP_TOKEN_FILE: /run/secrets/op_guardian_mcp_token diff --git a/packages/ui-kit/package.json b/packages/ui-kit/package.json new file mode 100644 index 000000000..7d8016ced --- /dev/null +++ b/packages/ui-kit/package.json @@ -0,0 +1,37 @@ +{ + "name": "@openpalm/ui-kit", + "version": "0.12.52", + "description": "OpenPalm shared UI kit — presentational components, icons, and theme tokens consumed as raw Svelte/TS source by the ui and client apps (plan ui-runtime-modes-plan.md §6.11). NOT published; no build step.", + "private": true, + "license": "MPL-2.0", + "type": "module", + "repository": { + "type": "git", + "url": "https://github.com/itlackey/openpalm.git", + "directory": "packages/ui-kit" + }, + "//exports": "Raw-source package: every export points at .svelte/.ts/.css source under src/lib. The consuming app's Vite/Svelte pipeline compiles it as part of the app build — there is deliberately no build step here.", + "exports": { + "./components/*": { + "svelte": "./src/lib/components/*", + "default": "./src/lib/components/*" + }, + "./theme/*": "./src/lib/theme/*" + }, + "//check": "svelte-check gives the raw-source files their ONLY TS coverage: the consuming apps' vite builds strip types without checking, and packages/ui's check only validates ui-kit's public prop surface at usage sites. Must stay 0 errors / 0 warnings (--fail-on-warnings enforces both).", + "scripts": { + "check": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings", + "test": "bun test" + }, + "peerDependencies": { + "svelte": "^5.0.0" + }, + "//devDependencies": "svelte/vitest/vitest-browser-svelte back the co-located *.svelte.vitest.ts browser tests, which run through packages/ui's vitest browser project (bun's isolated linker gives each workspace its own node_modules, so the test files' bare imports must resolve from here).", + "devDependencies": { + "svelte": "^5.53.5", + "svelte-check": "^4.1.1", + "typescript": "^6.0.3", + "vitest": "^4.0.18", + "vitest-browser-svelte": "^2.0.2" + } +} diff --git a/packages/ui/src/lib/components/common/AuthGate.svelte b/packages/ui-kit/src/lib/components/common/AuthGate.svelte similarity index 96% rename from packages/ui/src/lib/components/common/AuthGate.svelte rename to packages/ui-kit/src/lib/components/common/AuthGate.svelte index b72605edc..d6063b4fe 100644 --- a/packages/ui/src/lib/components/common/AuthGate.svelte +++ b/packages/ui-kit/src/lib/components/common/AuthGate.svelte @@ -1,7 +1,7 @@ @@ -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/admin/automations/AutomationsTab.svelte b/packages/ui/src/lib/components/admin/automations/AutomationsTab.svelte index 65c35355f..9a5f38c31 100644 --- a/packages/ui/src/lib/components/admin/automations/AutomationsTab.svelte +++ b/packages/ui/src/lib/components/admin/automations/AutomationsTab.svelte @@ -1,8 +1,8 @@ + + + {#if onConversationSurface} + + + + + + {/if} + + + + + diff --git a/packages/ui/src/lib/components/chrome/ModeSwitch.svelte b/packages/ui/src/lib/components/chrome/ModeSwitch.svelte index 3b42258f1..b70ed25f3 100644 --- a/packages/ui/src/lib/components/chrome/ModeSwitch.svelte +++ b/packages/ui/src/lib/components/chrome/ModeSwitch.svelte @@ -1,17 +1,17 @@ @@ -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/components/chrome/SettingsDrawer.svelte b/packages/ui/src/lib/components/chrome/SettingsDrawer.svelte index f26b16329..28f5f45c7 100644 --- a/packages/ui/src/lib/components/chrome/SettingsDrawer.svelte +++ b/packages/ui/src/lib/components/chrome/SettingsDrawer.svelte @@ -2,10 +2,10 @@ import type { ThemePreference } from '$lib/theme-state.svelte.js'; import { afterNavigate } from '$app/navigation'; import { resolve } from '$app/paths'; - import Drawer from '$lib/components/common/Drawer.svelte'; - import IconButton from '$lib/components/common/IconButton.svelte'; + import Drawer from '@openpalm/ui-kit/components/common/Drawer.svelte'; + import IconButton from '@openpalm/ui-kit/components/common/IconButton.svelte'; import { themeService } from '$lib/theme-state.svelte.js'; - import IconSettings from '$lib/components/icons/IconSettings.svelte'; + import IconSettings from '@openpalm/ui-kit/components/icons/IconSettings.svelte'; interface Props { showManageAssistant?: boolean; @@ -55,11 +55,11 @@ diff --git a/packages/ui/src/lib/components/chrome/TabBar.svelte b/packages/ui/src/lib/components/chrome/TabBar.svelte index 5a536b5a0..c8b0b74e4 100644 --- a/packages/ui/src/lib/components/chrome/TabBar.svelte +++ b/packages/ui/src/lib/components/chrome/TabBar.svelte @@ -1,19 +1,19 @@ 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/+server.ts b/packages/ui/src/routes/admin/endpoints/+server.ts deleted file mode 100644 index 63fd1c89b..000000000 --- a/packages/ui/src/routes/admin/endpoints/+server.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * /admin/endpoints — list and create assistant endpoints. - * - * The "default" entry is synthesized from environment (OP_OPENCODE_URL etc.) - * and is always first in the list. User-added endpoints are persisted to - * config/endpoints.json. Passwords are never returned — only - * `hasPassword: boolean`. - */ -import type { RequestHandler } from './$types'; -import { - errorResponse, - getRequestId, - jsonResponse, - requireAdmin, - withAdminBody, -} from '$lib/server/helpers.js'; -import { - addEndpoint, - getActiveEndpoint, - listEndpoints, - validateEndpointUrl, - type ActiveEndpoint, -} from '$lib/server/endpoints.js'; - -type PublicEndpoint = { - id: string; - label: string; - url: string; - isDefault: boolean; - hasPassword: boolean; -}; - -function publish(e: ActiveEndpoint): PublicEndpoint { - return { - id: e.id, - label: e.label, - url: e.url, - isDefault: e.isDefault, - hasPassword: Boolean(e.password), - }; -} - -export const GET: RequestHandler = async (event) => { - const requestId = getRequestId(event); - const authError = requireAdmin(event, requestId); - if (authError) return authError; - - const endpoints = listEndpoints().map(publish); - const active = publish(getActiveEndpoint()); - - return jsonResponse(200, { endpoints, activeId: active.id }, requestId); -}; - -export const POST: RequestHandler = async (event) => - 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 = validateEndpointUrl(url); - if (!urlCheck.ok) { - return errorResponse(400, 'invalid_endpoint', 'URL must be a valid http(s) URL', {}, requestId); - } - - try { - const entry = addEndpoint({ label, url: urlCheck.url, password }); - return jsonResponse(201, { endpoint: publish({ ...entry, isDefault: false }) }, requestId); - } catch (e) { - const msg = e instanceof Error ? e.message : 'failed to create endpoint'; - return errorResponse(400, 'invalid_endpoint', msg, {}, requestId); - } - }); diff --git a/packages/ui/src/routes/admin/endpoints/[id]/+server.ts b/packages/ui/src/routes/admin/endpoints/[id]/+server.ts deleted file mode 100644 index e1e8a9d16..000000000 --- a/packages/ui/src/routes/admin/endpoints/[id]/+server.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * /admin/endpoints/[id] — update or delete a user-added endpoint. - * - * 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, - requireAdmin, - withAdminBody, -} from '$lib/server/helpers.js'; -import { - deleteEndpoint, - updateEndpoint, - validateEndpointUrl, - type EndpointPatch, -} from '$lib/server/endpoints.js'; - -export const PATCH: RequestHandler = async (event) => - withAdminBody(event, async ({ requestId, body }) => { - const id = event.params.id; - const patch: EndpointPatch = {}; - if (typeof body.label === 'string') patch.label = body.label; - if (typeof body.url === 'string') { - const urlCheck = validateEndpointUrl(body.url); - if (!urlCheck.ok) { - return errorResponse(400, 'invalid_endpoint', '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 = updateEndpoint(id, patch); - return jsonResponse( - 200, - { - endpoint: { - id: entry.id, - label: entry.label, - url: entry.url, - isDefault: false, - hasPassword: Boolean(entry.password), - }, - }, - requestId, - ); - } catch (e) { - const msg = e instanceof Error ? e.message : 'failed to update endpoint'; - const status = msg.startsWith('Endpoint not found') ? 404 : 400; - return errorResponse(status, status === 404 ? 'not_found' : 'invalid_endpoint', msg, {}, requestId); - } - }); - -export const DELETE: RequestHandler = async (event) => { - const requestId = getRequestId(event); - const authError = requireAdmin(event, requestId); - if (authError) return authError; - - const id = event.params.id; - try { - deleteEndpoint(id); - return jsonResponse(200, { ok: true }, requestId); - } catch (e) { - const msg = e instanceof Error ? e.message : 'failed to delete endpoint'; - const status = msg.startsWith('Endpoint not found') ? 404 : 400; - return errorResponse(status, status === 404 ? 'not_found' : 'invalid_endpoint', msg, {}, requestId); - } -}; diff --git a/packages/ui/src/routes/admin/endpoints/active/+server.ts b/packages/ui/src/routes/admin/endpoints/active/+server.ts deleted file mode 100644 index 9e4396d4b..000000000 --- a/packages/ui/src/routes/admin/endpoints/active/+server.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * POST /admin/endpoints/active — set the active assistant endpoint. - * - * Body: { id: string } — pass "default" to revert to the env-derived entry. - */ -import type { RequestHandler } from './$types'; -import { - errorResponse, - jsonResponse, - withAdminBody, -} from '$lib/server/helpers.js'; -import { setActiveId } from '$lib/server/endpoints.js'; - -export const POST: RequestHandler = async (event) => - 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 = setActiveId(id); - return jsonResponse( - 200, - { - activeId: active.id, - endpoint: { - id: active.id, - label: active.label, - url: active.url, - isDefault: active.isDefault, - hasPassword: Boolean(active.password), - }, - }, - requestId, - ); - } catch (e) { - const msg = e instanceof Error ? e.message : 'failed to set active endpoint'; - return errorResponse(404, 'not_found', msg, {}, requestId); - } - }); diff --git a/packages/ui/src/routes/advanced/+page.svelte b/packages/ui/src/routes/advanced/+page.svelte index 00ad2c476..4a300fc42 100644 --- a/packages/ui/src/routes/advanced/+page.svelte +++ b/packages/ui/src/routes/advanced/+page.svelte @@ -2,7 +2,7 @@ import { page } from '$app/state'; import { afterNavigate } from '$app/navigation'; import { onMount } from 'svelte'; - import Navbar from '$lib/components/chrome/Navbar.svelte'; + import ChatNavbar from '$lib/components/chrome/ChatNavbar.svelte'; import { buildAdvancedIframeUrl } from '$lib/chat/navigation.js'; import { endpointsService } from '$lib/endpoints-state.svelte.js'; import { chat } from '$lib/chat/chat-state.svelte.js'; @@ -106,7 +106,7 @@ Advanced Chat — OpenPalm - +
{#if probeState === 'ready'} diff --git a/packages/ui/src/routes/admin/akm/+server.ts b/packages/ui/src/routes/api/assistant/akm/+server.ts similarity index 96% rename from packages/ui/src/routes/admin/akm/+server.ts rename to packages/ui/src/routes/api/assistant/akm/+server.ts index 7656bdad6..760de4fd8 100644 --- a/packages/ui/src/routes/admin/akm/+server.ts +++ b/packages/ui/src/routes/api/assistant/akm/+server.ts @@ -1,6 +1,13 @@ /** - * GET /admin/akm — Return current akm config from OP_HOME/config/akm/config.json - * PATCH /admin/akm — Update config fields aligned with AKM 0.8.0 schema + * GET /api/assistant/akm — Return current akm config from OP_HOME/config/akm/config.json + * PATCH /api/assistant/akm — Update config fields aligned with AKM 0.8.0 schema + * + * Assistant-SCOPED AKM configuration (plan ui-runtime-modes-plan.md Phase 4 + * steps 2+3, §6.4): config/akm/config.json is a read/write mount of the + * assistant container (plan §6.9), so assistant-container mode CAN edit it. + * Guarded by the assistant-settings capabilities in addition to requireAdmin; + * pwa-static is refused with 403 even for a valid admin session (plan §8.5). + * Host-LEVEL AKM (host key sharing) stays at /api/host/akm/host-sharing. */ import type { RequestHandler } from './$types'; import { readFileSync, mkdirSync, existsSync } from 'node:fs'; @@ -13,6 +20,7 @@ import { parseJsonBody, jsonBodyError, requireAdmin, + requireCapability, } from '$lib/server/helpers.js'; type Rec = Record; @@ -137,6 +145,8 @@ function validateImproveProcess(proc: Rec, path: string): Error | null { export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'assistant-settings:read', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; @@ -146,6 +156,8 @@ export const GET: RequestHandler = async (event) => { export const PATCH: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'assistant-settings:write', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/api/assistant/akm/server.vitest.ts b/packages/ui/src/routes/api/assistant/akm/server.vitest.ts new file mode 100644 index 000000000..6f8a50ef3 --- /dev/null +++ b/packages/ui/src/routes/api/assistant/akm/server.vitest.ts @@ -0,0 +1,166 @@ +/** + * Tests for /api/assistant/akm — assistant-SCOPED AKM configuration (plan + * ui-runtime-modes-plan.md Phase 4 steps 2+3, §6.4). + * + * ALL RED until Phase 4 lands: routes/api/assistant/akm/+server.ts does not + * exist yet (it is the move of the assistant-scoped part of + * routes/admin/akm/+server.ts — GET + PATCH over config/akm/config.json). + * Loaded via computed-specifier dynamic import so svelte-check stays clean + * while red. + * + * Contract under test — the AkmTab split (plan §9 "AKM"): + * - The AKM runtime config (config/akm/config.json — a read/write mount of + * the assistant container, plan §6.9) is assistant-scoped → lives under + * /api/assistant/akm, guarded by the assistant-settings capabilities + + * requireAdmin. + * - Phase 4 acceptance: assistant-container CAN edit AKM → GET/PATCH 200. + * - pwa-static has no assistant-settings capability → 403 with a valid + * session (capability-based, not session-based; plan §8.5). + * - Host-LEVEL AKM (host key sharing) stays under /api/host — pinned by + * routes/api/host/guard-hygiene.vitest.ts, not here. + */ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { mkdirSync, readFileSync, 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'; + +type RouteHandler = (event: unknown) => Response | Promise; +type AssistantAkmRouteModule = { GET: RouteHandler; PATCH: RouteHandler }; + +/** RED-state-safe loader (same pattern as the Phase 2 /api/connections suite). */ +async function loadRoute(): Promise { + const specifier = './+server.js'; + return (await import(/* @vite-ignore */ specifier)) as AssistantAkmRouteModule; +} + +let homeDir = ''; + +function makeTempHome(): string { + const dir = join(tmpdir(), `openpalm-assistant-akm-${randomBytes(4).toString('hex')}`); + mkdirSync(dir, { recursive: true }); + return trackDir(dir); +} + +function akmConfigFile(): string { + return join(homeDir, 'config', 'akm', 'config.json'); +} + +function seedAkmConfig(config: Record): void { + mkdirSync(join(homeDir, 'config', 'akm'), { recursive: true }); + writeFileSync(akmConfigFile(), JSON.stringify(config)); +} + +function makeGetEvent(token = 'admin-token'): unknown { + const url = new URL('http://127.0.0.1:3880/api/assistant/akm'); + return { + url, + request: new Request(url, { + headers: { + ...(token ? { cookie: `op_session=${token}` } : {}), + 'x-request-id': 'req-assistant-akm-get', + }, + }), + params: {}, + locals: { role: token ? 'admin' : null }, + route: { id: '/api/assistant/akm' }, + getClientAddress: () => '127.0.0.1', + isDataRequest: false, + isSubRequest: false, + }; +} + +function makePatchEvent(body: Record, token = 'admin-token'): unknown { + const url = new URL('http://127.0.0.1:3880/api/assistant/akm'); + return { + url, + request: new Request(url, { + method: 'PATCH', + headers: { + ...(token ? { cookie: `op_session=${token}` } : {}), + 'x-request-id': 'req-assistant-akm-patch', + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }), + params: {}, + locals: { role: token ? 'admin' : null }, + route: { id: '/api/assistant/akm' }, + getClientAddress: () => '127.0.0.1', + isDataRequest: false, + isSubRequest: false, + }; +} + +const ENV_KEYS = [ + 'OP_UI_HOST_MODE', + 'OP_INSIDE_ELECTRON', + 'OP_ENABLE_ADMIN', + 'OP_HOME', + '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]; + } + homeDir = makeTempHome(); + process.env.OP_HOME = homeDir; + 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/assistant/akm — assistant-scoped AKM config (plan Phase 4 step 2)', () => { + test('200 in assistant-container mode with a valid session — returns the config', async () => { + process.env.OP_UI_HOST_MODE = 'assistant-container'; + seedAkmConfig({ defaults: { llm: 'main' } }); + const { GET } = await loadRoute(); + const res = await GET(makeGetEvent()); + expect(res.status).toBe(200); + const body = (await res.json()) as { config: Record }; + expect((body.config.defaults as Record).llm).toBe('main'); + }); + + test('403 in pwa-static mode even with a valid admin session', async () => { + process.env.OP_UI_HOST_MODE = 'pwa-static'; + const { GET } = await loadRoute(); + const res = await GET(makeGetEvent()); + expect(res.status).toBe(403); + }); + + test('401 without a session cookie (requireAdmin still enforced)', async () => { + process.env.OP_UI_HOST_MODE = 'assistant-container'; + const { GET } = await loadRoute(); + const res = await GET(makeGetEvent('')); + expect(res.status).toBe(401); + }); +}); + +describe('PATCH /api/assistant/akm — assistant-container can edit AKM (Phase 4 acceptance)', () => { + test('200 in assistant-container mode: the patch is persisted to config/akm/config.json', async () => { + process.env.OP_UI_HOST_MODE = 'assistant-container'; + const { PATCH } = await loadRoute(); + const res = await PATCH(makePatchEvent({ defaults: { llm: 'primary' } })); + expect(res.status).toBe(200); + expect(readFileSync(akmConfigFile(), 'utf-8')).toContain('primary'); + }); + + test('403 in pwa-static mode even with a valid admin session', async () => { + process.env.OP_UI_HOST_MODE = 'pwa-static'; + const { PATCH } = await loadRoute(); + const res = await PATCH(makePatchEvent({ defaults: { llm: 'primary' } })); + expect(res.status).toBe(403); + }); +}); diff --git a/packages/ui/src/routes/admin/opencode/model/+server.ts b/packages/ui/src/routes/api/assistant/model/+server.ts similarity index 87% rename from packages/ui/src/routes/admin/opencode/model/+server.ts rename to packages/ui/src/routes/api/assistant/model/+server.ts index d3d5814e5..f486078c1 100644 --- a/packages/ui/src/routes/admin/opencode/model/+server.ts +++ b/packages/ui/src/routes/api/assistant/model/+server.ts @@ -1,6 +1,6 @@ /** - * GET /admin/opencode/model — Return OpenCode's current default + small models. - * POST /admin/opencode/model — Set OpenCode's default and/or small model. + * GET /api/assistant/model — Return OpenCode's current default + small models. + * POST /api/assistant/model — Set OpenCode's default and/or small model. * * These are OpenCode's own settings — the same fields its desktop UI's * Settings → Models tab manages. We only touch opencode.json (via @@ -10,6 +10,7 @@ import type { RequestHandler } from './$types'; import { requireAdmin, + requireCapability, jsonResponse, errorResponse, getRequestId, @@ -21,6 +22,8 @@ import { setMainModel, unsetMainModel } from '$lib/server/opencode/config.js'; export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'assistant-settings:read', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; @@ -51,6 +54,8 @@ function parseProviderModel(raw: unknown): { provider: string; model: string } | export const POST: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'assistant-settings:write', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/opencode/model/server.vitest.ts b/packages/ui/src/routes/api/assistant/model/server.vitest.ts similarity index 93% rename from packages/ui/src/routes/admin/opencode/model/server.vitest.ts rename to packages/ui/src/routes/api/assistant/model/server.vitest.ts index 8c172285e..aac836f60 100644 --- a/packages/ui/src/routes/admin/opencode/model/server.vitest.ts +++ b/packages/ui/src/routes/api/assistant/model/server.vitest.ts @@ -34,7 +34,7 @@ let originalHome: string | undefined; function makeEvent(method: string, body?: unknown, token = 'admin-token'): Parameters[0] { return { - request: new Request('http://localhost/admin/opencode/model', { + request: new Request('http://localhost/api/assistant/model', { method, headers: { 'content-type': 'application/json', @@ -47,6 +47,9 @@ function makeEvent(method: string, body?: unknown, token = 'admin-token'): Param } beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; rootDir = makeTempDir(); originalHome = process.env.OP_HOME; process.env.OP_HOME = rootDir; @@ -54,12 +57,13 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; process.env.OP_HOME = originalHome; rmSync(rootDir, { recursive: true, force: true }); vi.clearAllMocks(); }); -describe('/admin/opencode/model route', () => { +describe('/api/assistant/model route', () => { test('requires admin token', async () => { const res = await GET(makeEvent('GET', undefined, 'bad-token')); expect(res.status).toBe(401); diff --git a/packages/ui/src/routes/api/assistant/persona/+server.ts b/packages/ui/src/routes/api/assistant/persona/+server.ts new file mode 100644 index 000000000..e34da422e --- /dev/null +++ b/packages/ui/src/routes/api/assistant/persona/+server.ts @@ -0,0 +1,85 @@ +/** + * GET/PUT /api/assistant/persona — the assistant-owned persona markdown + * (plan ui-runtime-modes-plan.md Phase 4 step 2, §5.F, §6.4). + * + * The ASSISTANT-SCOPED half of the old /admin/assistant endpoint. Persona + * lives in config/assistant/persona.md — one of the two read/write mounts the + * assistant container keeps (plan §6.9) — so assistant-container mode CAN + * edit it, while pwa-static (no assistant-settings capability) is refused + * with 403 even for a valid admin session (plan §8.5). + */ +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { RequestHandler } from './$types'; +import { writeFileAtomic } from '@openpalm/lib'; +import { getState } from '$lib/server/state.js'; +import { + errorResponse, + getRequestId, + jsonResponse, + requireAdmin, + requireCapability, + withAdminBody, +} from '$lib/server/helpers.js'; + +function personaPath(configDir: string): string { + return join(configDir, 'assistant', 'persona.md'); +} + +function readPersona(configDir: string): string { + const path = personaPath(configDir); + if (!existsSync(path)) return ''; + try { + return readFileSync(path, 'utf-8'); + } catch { + return ''; + } +} + +export const GET: RequestHandler = async (event) => { + const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'assistant-settings:read', requestId); + if (capabilityError) return capabilityError; + const denied = requireAdmin(event, requestId); + if (denied) return denied; + + const state = getState(); + return jsonResponse( + 200, + { + personaPath: 'config/assistant/persona.md', + personaContent: readPersona(state.configDir), + }, + requestId, + ); +}; + +export const PUT: RequestHandler = async (event) => { + const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'assistant-settings:write', requestId); + if (capabilityError) return capabilityError; + + return withAdminBody(event, async ({ requestId, body }) => { + if (typeof body.personaContent !== 'string') { + return errorResponse(400, 'bad_request', 'personaContent must be a string', {}, requestId); + } + + const state = getState(); + const path = personaPath(state.configDir); + mkdirSync(join(state.configDir, 'assistant'), { recursive: true }); + const personaContent = body.personaContent.endsWith('\n') + ? body.personaContent + : `${body.personaContent}\n`; + writeFileAtomic(path, personaContent, 0o644); + + return jsonResponse( + 200, + { + ok: true, + personaPath: 'config/assistant/persona.md', + personaContent, + }, + requestId, + ); + }); +}; diff --git a/packages/ui/src/routes/api/assistant/persona/server.vitest.ts b/packages/ui/src/routes/api/assistant/persona/server.vitest.ts new file mode 100644 index 000000000..cfd3fba3b --- /dev/null +++ b/packages/ui/src/routes/api/assistant/persona/server.vitest.ts @@ -0,0 +1,183 @@ +/** + * Tests for /api/assistant/persona — the ASSISTANT-OWNED half of the old + * /admin/assistant endpoint (plan ui-runtime-modes-plan.md Phase 4 step 2, + * §5.F, §6.4 "/api/assistant/* — assistant-owned settings"). + * + * ALL RED until Phase 4 lands: routes/api/assistant/persona/+server.ts does + * not exist yet. Loaded via computed-specifier dynamic import so + * svelte-check stays clean while red. + * + * Contract under test: + * - Persona is assistant-scoped (config/assistant/persona.md — one of the + * two read/write mounts the assistant container keeps, plan §6.9). It is + * served from /api/assistant/persona guarded by the assistant-settings + * capabilities + the requireAdmin cookie check. + * - Phase 4 acceptance: assistant-container CAN edit persona — its + * serverCapabilities carry assistant-settings:read/write → 200 here. + * - Host modes keep assistant-settings:write → 200 in host-ui too. + * - pwa-static carries NO assistant-settings capability → 403 even with a + * valid admin session (capability-based, not session-based; plan §8.5). + */ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { mkdirSync, readFileSync, 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'; + +type RouteHandler = (event: unknown) => Response | Promise; +type PersonaRouteModule = { GET: RouteHandler; PUT: RouteHandler }; + +/** RED-state-safe loader (same pattern as the Phase 2 /api/connections suite). */ +async function loadRoute(): Promise { + const specifier = './+server.js'; + return (await import(/* @vite-ignore */ specifier)) as PersonaRouteModule; +} + +let homeDir = ''; + +function makeTempHome(): string { + const dir = join(tmpdir(), `openpalm-assistant-persona-${randomBytes(4).toString('hex')}`); + mkdirSync(dir, { recursive: true }); + return trackDir(dir); +} + +function personaFile(): string { + return join(homeDir, 'config', 'assistant', 'persona.md'); +} + +function seedPersona(content: string): void { + mkdirSync(join(homeDir, 'config', 'assistant'), { recursive: true }); + writeFileSync(personaFile(), content); +} + +function makeGetEvent(token = 'admin-token'): unknown { + const url = new URL('http://127.0.0.1:3880/api/assistant/persona'); + return { + url, + request: new Request(url, { + headers: { + ...(token ? { cookie: `op_session=${token}` } : {}), + 'x-request-id': 'req-assistant-persona-get', + }, + }), + params: {}, + locals: { role: token ? 'admin' : null }, + route: { id: '/api/assistant/persona' }, + getClientAddress: () => '127.0.0.1', + isDataRequest: false, + isSubRequest: false, + }; +} + +function makePutEvent(body: Record, token = 'admin-token'): unknown { + const url = new URL('http://127.0.0.1:3880/api/assistant/persona'); + return { + url, + request: new Request(url, { + method: 'PUT', + headers: { + ...(token ? { cookie: `op_session=${token}` } : {}), + 'x-request-id': 'req-assistant-persona-put', + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }), + params: {}, + locals: { role: token ? 'admin' : null }, + route: { id: '/api/assistant/persona' }, + getClientAddress: () => '127.0.0.1', + isDataRequest: false, + isSubRequest: false, + }; +} + +const ENV_KEYS = [ + 'OP_UI_HOST_MODE', + 'OP_INSIDE_ELECTRON', + 'OP_ENABLE_ADMIN', + 'OP_HOME', + '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]; + } + homeDir = makeTempHome(); + process.env.OP_HOME = homeDir; + 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/assistant/persona — assistant-settings guard (plan Phase 4 step 2)', () => { + test('200 in assistant-container mode with a valid session — returns the persona', async () => { + process.env.OP_UI_HOST_MODE = 'assistant-container'; + seedPersona('# Persona\n'); + const { GET } = await loadRoute(); + const res = await GET(makeGetEvent()); + expect(res.status).toBe(200); + const body = (await res.json()) as Record; + expect(body.personaContent).toBe('# Persona\n'); + }); + + test('403 in pwa-static mode even with a valid admin session', async () => { + process.env.OP_UI_HOST_MODE = 'pwa-static'; + const { GET } = await loadRoute(); + const res = await GET(makeGetEvent()); + expect(res.status).toBe(403); + }); + + test('401 without a session cookie (requireAdmin still enforced)', async () => { + process.env.OP_UI_HOST_MODE = 'assistant-container'; + const { GET } = await loadRoute(); + const res = await GET(makeGetEvent('')); + expect(res.status).toBe(401); + }); +}); + +describe('PUT /api/assistant/persona — assistant-settings:write (Phase 4 acceptance)', () => { + test('assistant-container CAN edit the persona: 200 + write to config/assistant/persona.md', async () => { + process.env.OP_UI_HOST_MODE = 'assistant-container'; + const { PUT } = await loadRoute(); + const res = await PUT(makePutEvent({ personaContent: '# Updated persona' })); + expect(res.status).toBe(200); + expect(readFileSync(personaFile(), 'utf-8').trimEnd()).toBe('# Updated persona'); + }); + + test('200 in host-ui mode (host modes keep assistant-settings:write)', async () => { + process.env.OP_UI_HOST_MODE = 'host-ui'; + const { PUT } = await loadRoute(); + const res = await PUT(makePutEvent({ personaContent: '# Host-edited persona' })); + expect(res.status).toBe(200); + expect(readFileSync(personaFile(), 'utf-8').trimEnd()).toBe('# Host-edited persona'); + }); + + test('403 in pwa-static mode even with a valid session — capability guard, not auth', async () => { + process.env.OP_UI_HOST_MODE = 'pwa-static'; + const { PUT } = await loadRoute(); + const res = await PUT(makePutEvent({ personaContent: 'nope' })); + expect(res.status).toBe(403); + expect(res.headers.get('content-type') ?? '').toContain('application/json'); + const body = (await res.json()) as Record; + expect(body.error).toBe('capability_not_available'); + }); + + test('401 without a session cookie', async () => { + process.env.OP_UI_HOST_MODE = 'assistant-container'; + const { PUT } = await loadRoute(); + const res = await PUT(makePutEvent({ personaContent: '# P' }, '')); + expect(res.status).toBe(401); + }); +}); diff --git a/packages/ui/src/routes/admin/auth/login/+server.ts b/packages/ui/src/routes/api/auth/login/+server.ts similarity index 98% rename from packages/ui/src/routes/admin/auth/login/+server.ts rename to packages/ui/src/routes/api/auth/login/+server.ts index d85e0a14c..4f248e8b1 100644 --- a/packages/ui/src/routes/admin/auth/login/+server.ts +++ b/packages/ui/src/routes/api/auth/login/+server.ts @@ -1,5 +1,5 @@ /** - * POST /admin/auth/login + * POST /api/auth/login * * Issues the `op_session` cookie (HttpOnly, SameSite=Lax, Secure-on-HTTPS, * Max-Age=14d — see session-cookie.ts) after verifying the operator-supplied diff --git a/packages/ui/src/routes/admin/auth/login/server.vitest.ts b/packages/ui/src/routes/api/auth/login/server.vitest.ts similarity index 91% rename from packages/ui/src/routes/admin/auth/login/server.vitest.ts rename to packages/ui/src/routes/api/auth/login/server.vitest.ts index 454a3f298..37a3fc865 100644 --- a/packages/ui/src/routes/admin/auth/login/server.vitest.ts +++ b/packages/ui/src/routes/api/auth/login/server.vitest.ts @@ -1,8 +1,8 @@ /** - * Route-level tests for POST /admin/auth/login. + * Route-level tests for POST /api/auth/login. * * Previously untested anywhere (3.4) — the route mints the `op_session` - * cookie operators authenticate with for every subsequent /admin/* call, so + * cookie operators authenticate with for every subsequent privileged API call, so * its four branches (misconfigured, bad body, wrong password, success) are * the entire login contract. */ @@ -16,12 +16,12 @@ let savedEnv: string | undefined; function makeLoginEvent(body: unknown, asJson = true): Parameters[0] { return { request: asJson - ? new Request('http://localhost/admin/auth/login', { + ? new Request('http://localhost/api/auth/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }) - : new Request('http://localhost/admin/auth/login', { + : new Request('http://localhost/api/auth/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: 'not-json{{{', @@ -40,7 +40,7 @@ afterEach(() => { _clearSessions(); }); -describe('POST /admin/auth/login', () => { +describe('POST /api/auth/login', () => { test('returns 503 admin_not_configured when no login password is set', async () => { delete process.env[ENV_KEY]; const res = await POST(makeLoginEvent({ password: 'anything' })); diff --git a/packages/ui/src/routes/admin/auth/logout/+server.ts b/packages/ui/src/routes/api/auth/logout/+server.ts similarity index 100% rename from packages/ui/src/routes/admin/auth/logout/+server.ts rename to packages/ui/src/routes/api/auth/logout/+server.ts diff --git a/packages/ui/src/routes/admin/auth/logout/server.vitest.ts b/packages/ui/src/routes/api/auth/logout/server.vitest.ts similarity index 91% rename from packages/ui/src/routes/admin/auth/logout/server.vitest.ts rename to packages/ui/src/routes/api/auth/logout/server.vitest.ts index bffaa25db..f36309e51 100644 --- a/packages/ui/src/routes/admin/auth/logout/server.vitest.ts +++ b/packages/ui/src/routes/api/auth/logout/server.vitest.ts @@ -1,5 +1,5 @@ /** - * Route-level tests for POST /admin/auth/logout. + * Route-level tests for POST /api/auth/logout. * * Previously untested anywhere (3.4). Logout must always succeed (idempotent * — no auth required, matching a browser that may already be logged out) and @@ -16,7 +16,7 @@ function makeLogoutEvent(cookie?: string): Parameters[0] { const headers: Record = {}; if (cookie !== undefined) headers.cookie = cookie; return { - request: new Request('http://localhost/admin/auth/logout', { method: 'POST', headers }), + request: new Request('http://localhost/api/auth/logout', { method: 'POST', headers }), } as Parameters[0]; } @@ -32,7 +32,7 @@ afterEach(() => { _clearSessions(); }); -describe('POST /admin/auth/logout', () => { +describe('POST /api/auth/logout', () => { test('returns 200 ok and clears the cookie even with no cookie presented', async () => { const res = await POST(makeLogoutEvent()); expect(res.status).toBe(200); diff --git a/packages/ui/src/routes/admin/auth/session/+server.ts b/packages/ui/src/routes/api/auth/session/+server.ts similarity index 94% rename from packages/ui/src/routes/admin/auth/session/+server.ts rename to packages/ui/src/routes/api/auth/session/+server.ts index 4182a7050..4d45c855b 100644 --- a/packages/ui/src/routes/admin/auth/session/+server.ts +++ b/packages/ui/src/routes/api/auth/session/+server.ts @@ -1,11 +1,11 @@ /** - * POST /admin/auth/session + * POST /api/auth/session * * Issues an `op_session` cookie after verifying the operator-supplied password * against `process.env.OP_UI_LOGIN_PASSWORD`. * * The cookie value is a random UUID session token — NOT the plaintext password. - * Kept alongside `/admin/auth/login` as an alias; both verify the same + * Kept alongside `/api/auth/login` as an alias; both verify the same * `password` body field and issue the `op_session` cookie. */ import { safeTokenCompare, getRequestId, errorResponse, getUiLoginPassword } from "$lib/server/helpers.js"; diff --git a/packages/ui/src/routes/admin/auth/session/server.vitest.ts b/packages/ui/src/routes/api/auth/session/server.vitest.ts similarity index 92% rename from packages/ui/src/routes/admin/auth/session/server.vitest.ts rename to packages/ui/src/routes/api/auth/session/server.vitest.ts index cb746a98d..f69ece9c3 100644 --- a/packages/ui/src/routes/admin/auth/session/server.vitest.ts +++ b/packages/ui/src/routes/api/auth/session/server.vitest.ts @@ -1,5 +1,5 @@ /** - * Route-level tests for POST /admin/auth/session. + * Route-level tests for POST /api/auth/session. * * Previously untested anywhere (3.4). Kept as a login alias (same password * check, same cookie issuance) — these tests mirror the login route's, @@ -15,12 +15,12 @@ let savedEnv: string | undefined; function makeSessionEvent(body: unknown, asJson = true): Parameters[0] { return { request: asJson - ? new Request('http://localhost/admin/auth/session', { + ? new Request('http://localhost/api/auth/session', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }) - : new Request('http://localhost/admin/auth/session', { + : new Request('http://localhost/api/auth/session', { method: 'POST', headers: { 'content-type': 'application/json' }, body: 'not-json{{{', @@ -39,7 +39,7 @@ afterEach(() => { _clearSessions(); }); -describe('POST /admin/auth/session', () => { +describe('POST /api/auth/session', () => { test('returns 503 admin_not_configured when no login password is set', async () => { delete process.env[ENV_KEY]; const res = await POST(makeSessionEvent({ password: 'anything' })); 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); + } + }); +}; 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); + }); +}); diff --git a/packages/ui/src/routes/admin/addons/+server.ts b/packages/ui/src/routes/api/host/addons/+server.ts similarity index 84% rename from packages/ui/src/routes/admin/addons/+server.ts rename to packages/ui/src/routes/api/host/addons/+server.ts index e3c71d693..e0fb3e0d3 100644 --- a/packages/ui/src/routes/admin/addons/+server.ts +++ b/packages/ui/src/routes/api/host/addons/+server.ts @@ -1,6 +1,6 @@ /** - * GET /admin/addons — Return available addons with enabled status. - * POST /admin/addons — Enable or disable an addon. + * GET /api/host/addons — Return available addons with enabled status. + * POST /api/host/addons — Enable or disable an addon. */ import type { RequestHandler } from "./$types"; import { getState } from "$lib/server/state.js"; @@ -8,6 +8,7 @@ import { jsonResponse, errorResponse, requireAdmin, + requireCapability, getRequestId, parseJsonBody, jsonBodyError, @@ -27,6 +28,8 @@ function buildAddonList(availableIds: string[], enabledIds: string[]): AddonItem export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:addons', requestId); + if (capabilityError) return capabilityError; const authErr = requireAdmin(event, requestId); if (authErr) return authErr; @@ -39,6 +42,8 @@ export const GET: RequestHandler = async (event) => { export const POST: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:addons', requestId); + if (capabilityError) return capabilityError; const authErr = requireAdmin(event, requestId); if (authErr) return authErr; diff --git a/packages/ui/src/routes/admin/addons/[name]/+server.ts b/packages/ui/src/routes/api/host/addons/[name]/+server.ts similarity index 85% rename from packages/ui/src/routes/admin/addons/[name]/+server.ts rename to packages/ui/src/routes/api/host/addons/[name]/+server.ts index 1c50551f7..e53cf7d84 100644 --- a/packages/ui/src/routes/admin/addons/[name]/+server.ts +++ b/packages/ui/src/routes/api/host/addons/[name]/+server.ts @@ -1,6 +1,6 @@ /** - * GET /admin/addons/:name — Return addon detail. - * POST /admin/addons/:name — Enable or disable an addon. + * GET /api/host/addons/:name — Return addon detail. + * POST /api/host/addons/:name — Enable or disable an addon. */ import type { RequestHandler } from "./$types"; import { getState } from "$lib/server/state.js"; @@ -8,6 +8,7 @@ import { jsonResponse, errorResponse, requireAdmin, + requireCapability, getRequestId, parseJsonBody, jsonBodyError, @@ -24,6 +25,8 @@ const logger = createLogger("addons.name"); export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:addons', requestId); + if (capabilityError) return capabilityError; const authErr = requireAdmin(event, requestId); if (authErr) return authErr; @@ -48,6 +51,8 @@ export const GET: RequestHandler = async (event) => { export const POST: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:addons', requestId); + if (capabilityError) return capabilityError; const authErr = requireAdmin(event, requestId); if (authErr) return authErr; diff --git a/packages/ui/src/routes/admin/addons/[name]/credentials/+server.ts b/packages/ui/src/routes/api/host/addons/[name]/credentials/+server.ts similarity index 94% rename from packages/ui/src/routes/admin/addons/[name]/credentials/+server.ts rename to packages/ui/src/routes/api/host/addons/[name]/credentials/+server.ts index bce80bb89..356bdaf40 100644 --- a/packages/ui/src/routes/admin/addons/[name]/credentials/+server.ts +++ b/packages/ui/src/routes/api/host/addons/[name]/credentials/+server.ts @@ -1,7 +1,7 @@ /** - * GET /admin/addons/:name/credentials — Return the addon's .env.schema as + * GET /api/host/addons/:name/credentials — Return the addon's .env.schema as * structured fields plus secret presence metadata. - * POST /admin/addons/:name/credentials — Write supplied fields into + * POST /api/host/addons/:name/credentials — Write supplied fields into * knowledge/secrets/ (@sensitive) or knowledge/env/stack.env * (non-sensitive). Split determined by `# @sensitive` schema annotation. * Body shape: { values: { KEY: VALUE | "" } }. Empty strings clear the key. @@ -17,6 +17,7 @@ import { jsonResponse, errorResponse, requireAdmin, + requireCapability, getRequestId, parseJsonBody, jsonBodyError, @@ -99,6 +100,8 @@ function parseEnvSchema(text: string): SchemaField[] { export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:addons', requestId); + if (capabilityError) return capabilityError; const authErr = requireAdmin(event, requestId); if (authErr) return authErr; @@ -141,6 +144,8 @@ export const GET: RequestHandler = async (event) => { export const POST: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:addons', requestId); + if (capabilityError) return capabilityError; const authErr = requireAdmin(event, requestId); if (authErr) return authErr; diff --git a/packages/ui/src/routes/admin/addons/[name]/server.vitest.ts b/packages/ui/src/routes/api/host/addons/[name]/server.vitest.ts similarity index 92% rename from packages/ui/src/routes/admin/addons/[name]/server.vitest.ts rename to packages/ui/src/routes/api/host/addons/[name]/server.vitest.ts index b0d0c9d0c..0e9f9e3a3 100644 --- a/packages/ui/src/routes/admin/addons/[name]/server.vitest.ts +++ b/packages/ui/src/routes/api/host/addons/[name]/server.vitest.ts @@ -16,7 +16,7 @@ function makeTempDir(): string { function makeGetEvent(name: string, token = 'admin-token'): Parameters[0] { return { params: { name }, - request: new Request(`http://localhost/admin/addons/${name}`, { + request: new Request(`http://localhost/api/host/addons/${name}`, { headers: { cookie: `op_session=${token}`, 'x-request-id': 'req-addon-detail', @@ -28,7 +28,7 @@ function makeGetEvent(name: string, token = 'admin-token'): Parameters, token = 'admin-token'): Parameters[0] { return { params: { name }, - request: new Request(`http://localhost/admin/addons/${name}`, { + request: new Request(`http://localhost/api/host/addons/${name}`, { method: 'POST', headers: { cookie: `op_session=${token}`, @@ -61,18 +61,22 @@ function readStackEnvFile(homeDir: string): string { let originalHome: string | undefined; beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; originalHome = process.env.OP_HOME; process.env.OP_HOME = makeTempDir(); resetState('admin-token'); }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; process.env.OP_HOME = originalHome; cleanupTempDirs(); rmSync(getState().homeDir, { recursive: true, force: true }); }); -describe('/admin/addons/:name route', () => { +describe('/api/host/addons/:name route', () => { test('requires admin token', async () => { const res = await GET(makeGetEvent('discord', 'bad-token')); expect(res.status).toBe(401); @@ -114,7 +118,7 @@ describe('/admin/addons/:name route', () => { }); }); -describe('POST /admin/addons/:name', () => { +describe('POST /api/host/addons/:name', () => { test('requires admin token', async () => { const res = await POST(makePostEvent('discord', { enabled: true }, 'bad-token')); expect(res.status).toBe(401); diff --git a/packages/ui/src/routes/admin/addons/server.vitest.ts b/packages/ui/src/routes/api/host/addons/server.vitest.ts similarity index 93% rename from packages/ui/src/routes/admin/addons/server.vitest.ts rename to packages/ui/src/routes/api/host/addons/server.vitest.ts index b6bbee49d..fe25726db 100644 --- a/packages/ui/src/routes/admin/addons/server.vitest.ts +++ b/packages/ui/src/routes/api/host/addons/server.vitest.ts @@ -15,7 +15,7 @@ function makeTempDir(): string { function makeGetEvent(token = 'admin-token'): Parameters[0] { return { - request: new Request('http://localhost/admin/addons', { + request: new Request('http://localhost/api/host/addons', { headers: { cookie: `op_session=${token}`, 'x-request-id': 'req-addons-list', @@ -26,7 +26,7 @@ function makeGetEvent(token = 'admin-token'): Parameters[0] { function makePostEvent(body: Record, token = 'admin-token'): Parameters[0] { return { - request: new Request('http://localhost/admin/addons', { + request: new Request('http://localhost/api/host/addons', { method: 'POST', headers: { cookie: `op_session=${token}`, @@ -59,18 +59,22 @@ function readEnabledAddonsEnv(homeDir: string): string { let originalHome: string | undefined; beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; originalHome = process.env.OP_HOME; process.env.OP_HOME = makeTempDir(); resetState('admin-token'); }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; process.env.OP_HOME = originalHome; cleanupTempDirs(); rmSync(getState().homeDir, { recursive: true, force: true }); }); -describe('GET /admin/addons', () => { +describe('GET /api/host/addons', () => { test('requires admin token', async () => { const res = await GET(makeGetEvent('bad-token')); expect(res.status).toBe(401); @@ -100,7 +104,7 @@ describe('GET /admin/addons', () => { }); }); -describe('POST /admin/addons', () => { +describe('POST /api/host/addons', () => { test('requires admin token', async () => { const res = await POST(makePostEvent({ name: 'discord', enabled: true }, 'bad-token')); expect(res.status).toBe(401); diff --git a/packages/ui/src/routes/admin/akm/embedding/detect/+server.ts b/packages/ui/src/routes/api/host/akm/embedding/detect/+server.ts similarity index 77% rename from packages/ui/src/routes/admin/akm/embedding/detect/+server.ts rename to packages/ui/src/routes/api/host/akm/embedding/detect/+server.ts index 6aa46f7a6..eca858a06 100644 --- a/packages/ui/src/routes/admin/akm/embedding/detect/+server.ts +++ b/packages/ui/src/routes/api/host/akm/embedding/detect/+server.ts @@ -1,10 +1,12 @@ import type { RequestHandler } from './$types'; import { detectEmbeddingSettings } from '$lib/server/akm.js'; import { getState } from '$lib/server/state.js'; -import { errorResponse, getRequestId, jsonResponse, requireAdmin } from '$lib/server/helpers.js'; +import { errorResponse, getRequestId, jsonResponse, requireAdmin, requireCapability } from '$lib/server/helpers.js'; export const POST: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:stack:read', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/akm/embedding/server.vitest.ts b/packages/ui/src/routes/api/host/akm/embedding/server.vitest.ts similarity index 77% rename from packages/ui/src/routes/admin/akm/embedding/server.vitest.ts rename to packages/ui/src/routes/api/host/akm/embedding/server.vitest.ts index 18489537e..b5483791c 100644 --- a/packages/ui/src/routes/admin/akm/embedding/server.vitest.ts +++ b/packages/ui/src/routes/api/host/akm/embedding/server.vitest.ts @@ -35,6 +35,9 @@ function makeEvent(path: string, body?: unknown, token = 'admin-token') { } beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; rootDir = join(tmpdir(), `openpalm-akm-embedding-${randomBytes(4).toString('hex')}`); mkdirSync(rootDir, { recursive: true }); originalHome = process.env.OP_HOME; @@ -44,14 +47,15 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; process.env.OP_HOME = originalHome; rmSync(rootDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); - describe('POST /admin/akm/embedding/detect', () => { + describe('POST /api/host/akm/embedding/detect', () => { test('401 without auth', async () => { - expect((await detectPOST(makeEvent('/admin/akm/embedding/detect', {}, '') as Parameters[0])).status).toBe(401); + expect((await detectPOST(makeEvent('/api/host/akm/embedding/detect', {}, '') as Parameters[0])).status).toBe(401); }); test('returns detected settings', async () => { @@ -64,7 +68,7 @@ afterEach(() => { message: 'Detected ollama embedding model mxbai-embed-large:latest.', }); - const res = await detectPOST(makeEvent('/admin/akm/embedding/detect', {}) as Parameters[0]); + const res = await detectPOST(makeEvent('/api/host/akm/embedding/detect', {}) as Parameters[0]); expect(res.status).toBe(200); const body = await res.json() as Record; expect(body.endpoint).toBe('http://localhost:11434/v1/embeddings'); @@ -72,9 +76,9 @@ afterEach(() => { }); }); - describe('POST /admin/akm/embedding/test', () => { + describe('POST /api/host/akm/embedding/test', () => { test('400 for missing endpoint/model', async () => { - expect((await testPOST(makeEvent('/admin/akm/embedding/test', { endpoint: '', model: '' }) as Parameters[0])).status).toBe(400); + expect((await testPOST(makeEvent('/api/host/akm/embedding/test', { endpoint: '', model: '' }) as Parameters[0])).status).toBe(400); }); test('returns the probed dimension', async () => { @@ -85,7 +89,7 @@ afterEach(() => { provider: 'ollama', }); - const res = await testPOST(makeEvent('/admin/akm/embedding/test', { + const res = await testPOST(makeEvent('/api/host/akm/embedding/test', { endpoint: 'http://localhost:11434/v1/embeddings', model: 'mxbai-embed-large:latest', provider: 'ollama', diff --git a/packages/ui/src/routes/admin/akm/embedding/test/+server.ts b/packages/ui/src/routes/api/host/akm/embedding/test/+server.ts similarity index 85% rename from packages/ui/src/routes/admin/akm/embedding/test/+server.ts rename to packages/ui/src/routes/api/host/akm/embedding/test/+server.ts index 1d3004af5..6cd82fbb2 100644 --- a/packages/ui/src/routes/admin/akm/embedding/test/+server.ts +++ b/packages/ui/src/routes/api/host/akm/embedding/test/+server.ts @@ -1,9 +1,11 @@ import type { RequestHandler } from './$types'; import { testEmbeddingSettings } from '$lib/server/akm.js'; -import { errorResponse, getRequestId, jsonBodyError, parseJsonBody, jsonResponse, requireAdmin } from '$lib/server/helpers.js'; +import { errorResponse, getRequestId, jsonBodyError, parseJsonBody, jsonResponse, requireAdmin, requireCapability } from '$lib/server/helpers.js'; export const POST: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:stack:read', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/akm/health-report/+server.ts b/packages/ui/src/routes/api/host/akm/health-report/+server.ts similarity index 86% rename from packages/ui/src/routes/admin/akm/health-report/+server.ts rename to packages/ui/src/routes/api/host/akm/health-report/+server.ts index c2f40ee68..c76ba7bd6 100644 --- a/packages/ui/src/routes/admin/akm/health-report/+server.ts +++ b/packages/ui/src/routes/api/host/akm/health-report/+server.ts @@ -1,6 +1,6 @@ import type { RequestHandler } from './$types'; import { getState } from '$lib/server/state.js'; -import { getRequestId, requireAdmin } from '$lib/server/helpers.js'; +import { getRequestId, requireAdmin, requireCapability } from '$lib/server/helpers.js'; import { buildAkmHealthReport } from '$lib/server/akm-health-report.js'; const REPORT_CSP = [ @@ -18,6 +18,8 @@ const REPORT_CSP = [ export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:containers', requestId); + if (capabilityError) return capabilityError; const denied = requireAdmin(event, requestId); if (denied) return denied; diff --git a/packages/ui/src/routes/admin/akm/health-report/server.vitest.ts b/packages/ui/src/routes/api/host/akm/health-report/server.vitest.ts similarity index 87% rename from packages/ui/src/routes/admin/akm/health-report/server.vitest.ts rename to packages/ui/src/routes/api/host/akm/health-report/server.vitest.ts index 7aff29439..83094dff8 100644 --- a/packages/ui/src/routes/admin/akm/health-report/server.vitest.ts +++ b/packages/ui/src/routes/api/host/akm/health-report/server.vitest.ts @@ -19,7 +19,7 @@ import { runAssistantAkmCommand } from '@openpalm/lib'; let rootDir = ''; let originalHome: string | undefined; -function makeEvent(path = '/admin/akm/health-report?since=72h', token = 'admin-token') { +function makeEvent(path = '/api/host/akm/health-report?since=72h', token = 'admin-token') { const url = new URL(`http://localhost${path}`); return { request: new Request(url, { @@ -35,6 +35,9 @@ function makeEvent(path = '/admin/akm/health-report?since=72h', token = 'admin-t } beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; rootDir = join(tmpdir(), `openpalm-akm-health-report-${randomBytes(4).toString('hex')}`); mkdirSync(rootDir, { recursive: true }); originalHome = process.env.OP_HOME; @@ -44,14 +47,15 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; process.env.OP_HOME = originalHome; rmSync(rootDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); -describe('GET /admin/akm/health-report', () => { +describe('GET /api/host/akm/health-report', () => { test('401 without auth', async () => { - expect((await GET(makeEvent('/admin/akm/health-report?since=72h', ''))).status).toBe(401); + expect((await GET(makeEvent('/api/host/akm/health-report?since=72h', ''))).status).toBe(401); }); test('renders html from live akm health --format html', async () => { @@ -85,7 +89,7 @@ describe('GET /admin/akm/health-report', () => { missing: false, }); - await GET(makeEvent('/admin/akm/health-report?since=999h')); + await GET(makeEvent('/api/host/akm/health-report?since=999h')); expect(runAssistantAkmCommand).toHaveBeenCalledWith( expect.anything(), ['health', '--since=72h', '--format', 'html'], diff --git a/packages/ui/src/routes/admin/akm/health/+server.ts b/packages/ui/src/routes/api/host/akm/health/+server.ts similarity index 86% rename from packages/ui/src/routes/admin/akm/health/+server.ts rename to packages/ui/src/routes/api/host/akm/health/+server.ts index ef293f351..f38d3102d 100644 --- a/packages/ui/src/routes/admin/akm/health/+server.ts +++ b/packages/ui/src/routes/api/host/akm/health/+server.ts @@ -1,5 +1,5 @@ /** - * GET /admin/akm/health — AKM runtime health + index stats for the dashboard. + * GET /api/host/akm/health — AKM runtime health + index stats for the dashboard. * * Runs `akm` inside the live assistant container so the UI shows the same AKM * world the assistant actually sees. Fails soft: if the assistant runtime is @@ -9,11 +9,13 @@ import type { RequestHandler } from './$types'; import { runAssistantAkmCommand } from '@openpalm/lib'; import { getState } from '$lib/server/state.js'; -import { getRequestId, jsonResponse, requireAdmin } from '$lib/server/helpers.js'; +import { getRequestId, jsonResponse, requireAdmin, requireCapability } from '$lib/server/helpers.js'; import { safeParseJsonObject } from '$lib/server/akm.js'; export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:containers', requestId); + if (capabilityError) return capabilityError; const denied = requireAdmin(event, requestId); if (denied) return denied; diff --git a/packages/ui/src/routes/admin/akm/host-sharing/+server.ts b/packages/ui/src/routes/api/host/akm/host-sharing/+server.ts similarity index 70% rename from packages/ui/src/routes/admin/akm/host-sharing/+server.ts rename to packages/ui/src/routes/api/host/akm/host-sharing/+server.ts index 92e697ec3..5e66fa3dc 100644 --- a/packages/ui/src/routes/admin/akm/host-sharing/+server.ts +++ b/packages/ui/src/routes/api/host/akm/host-sharing/+server.ts @@ -1,9 +1,9 @@ /** * Host AKM sharing control surface. * - * GET /admin/akm/host-sharing — { enabled, hostStashPath } - * PUT /admin/akm/host-sharing — enable: set OP_HOST_AKM_STASH + import profiles - * DELETE /admin/akm/host-sharing — disable: unset OP_HOST_AKM_STASH + * GET /api/host/akm/host-sharing — { enabled, hostStashPath } + * PUT /api/host/akm/host-sharing — enable: set OP_HOST_AKM_STASH + import profiles + * DELETE /api/host/akm/host-sharing — disable: unset OP_HOST_AKM_STASH * * /host-stash is always mounted (core.compose.yml). /host-stash is always a * secondary akm source. Enable/disable only changes what the compose mount @@ -20,10 +20,13 @@ import { getRequestId, jsonResponse, requireAdmin, + requireCapability, } from '$lib/server/helpers.js'; export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:akm-sharing', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; @@ -32,6 +35,8 @@ export const GET: RequestHandler = async (event) => { export const PUT: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:akm-sharing', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; @@ -42,6 +47,8 @@ export const PUT: RequestHandler = async (event) => { export const DELETE: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:akm-sharing', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/akm/host-sharing/server.vitest.ts b/packages/ui/src/routes/api/host/akm/host-sharing/server.vitest.ts similarity index 86% rename from packages/ui/src/routes/admin/akm/host-sharing/server.vitest.ts rename to packages/ui/src/routes/api/host/akm/host-sharing/server.vitest.ts index 4456ed356..76a71629a 100644 --- a/packages/ui/src/routes/admin/akm/host-sharing/server.vitest.ts +++ b/packages/ui/src/routes/api/host/akm/host-sharing/server.vitest.ts @@ -1,5 +1,5 @@ /** - * Tests for /admin/akm/host-sharing (GET status, PUT enable, DELETE disable). + * Tests for /api/host/akm/host-sharing (GET status, PUT enable, DELETE disable). * * Orchestration lives in @openpalm/lib (unit-tested there); here we assert the * HTTP surface: auth gating, response shape, and that the lib orchestrators are @@ -29,7 +29,7 @@ let rootDir = ''; let originalHome: string | undefined; function makeEvent(method: string, body?: unknown, token = 'admin-token') { - const url = new URL('http://localhost/admin/akm/host-sharing'); + const url = new URL('http://localhost/api/host/akm/host-sharing'); return { request: new Request(url, { method, @@ -46,6 +46,9 @@ function makeEvent(method: string, body?: unknown, token = 'admin-token') { } beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; rootDir = join(tmpdir(), `openpalm-host-sharing-${randomBytes(4).toString('hex')}`); mkdirSync(rootDir, { recursive: true }); originalHome = process.env.OP_HOME; @@ -57,12 +60,13 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; process.env.OP_HOME = originalHome; rmSync(rootDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); -describe('GET /admin/akm/host-sharing', () => { +describe('GET /api/host/akm/host-sharing', () => { test('401 without auth', async () => { expect((await GET(makeEvent('GET', undefined, ''))).status).toBe(401); }); @@ -75,7 +79,7 @@ describe('GET /admin/akm/host-sharing', () => { }); }); -describe('PUT /admin/akm/host-sharing', () => { +describe('PUT /api/host/akm/host-sharing', () => { test('401 without auth', async () => { expect((await PUT(makeEvent('PUT', {}, ''))).status).toBe(401); }); @@ -89,7 +93,7 @@ describe('PUT /admin/akm/host-sharing', () => { }); }); -describe('DELETE /admin/akm/host-sharing', () => { +describe('DELETE /api/host/akm/host-sharing', () => { test('401 without auth', async () => { expect((await DELETE(makeEvent('DELETE', undefined, ''))).status).toBe(401); }); diff --git a/packages/ui/src/routes/admin/akm/reindex/+server.ts b/packages/ui/src/routes/api/host/akm/reindex/+server.ts similarity index 82% rename from packages/ui/src/routes/admin/akm/reindex/+server.ts rename to packages/ui/src/routes/api/host/akm/reindex/+server.ts index 611bbfd4f..cd95fd914 100644 --- a/packages/ui/src/routes/admin/akm/reindex/+server.ts +++ b/packages/ui/src/routes/api/host/akm/reindex/+server.ts @@ -1,10 +1,12 @@ import type { RequestHandler } from './$types'; import { runAssistantAkmCommand } from '@openpalm/lib'; import { getState } from '$lib/server/state.js'; -import { errorResponse, getRequestId, jsonResponse, requireAdmin } from '$lib/server/helpers.js'; +import { errorResponse, getRequestId, jsonResponse, requireAdmin, requireCapability } from '$lib/server/helpers.js'; export const POST: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:containers', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/akm/reindex/server.vitest.ts b/packages/ui/src/routes/api/host/akm/reindex/server.vitest.ts similarity index 86% rename from packages/ui/src/routes/admin/akm/reindex/server.vitest.ts rename to packages/ui/src/routes/api/host/akm/reindex/server.vitest.ts index dbb12d69a..d695b9db0 100644 --- a/packages/ui/src/routes/admin/akm/reindex/server.vitest.ts +++ b/packages/ui/src/routes/api/host/akm/reindex/server.vitest.ts @@ -20,7 +20,7 @@ let rootDir = ''; let originalHome: string | undefined; function makeEvent(token = 'admin-token') { - const url = new URL('http://localhost/admin/akm/reindex'); + const url = new URL('http://localhost/api/host/akm/reindex'); return { request: new Request(url, { method: 'POST', @@ -37,6 +37,9 @@ function makeEvent(token = 'admin-token') { } beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; rootDir = join(tmpdir(), `openpalm-akm-reindex-${randomBytes(4).toString('hex')}`); mkdirSync(rootDir, { recursive: true }); originalHome = process.env.OP_HOME; @@ -46,12 +49,13 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; process.env.OP_HOME = originalHome; rmSync(rootDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); -describe('POST /admin/akm/reindex', () => { +describe('POST /api/host/akm/reindex', () => { test('401 without auth', async () => { expect((await POST(makeEvent(''))).status).toBe(401); }); diff --git a/packages/ui/src/routes/admin/akm/stats/+server.ts b/packages/ui/src/routes/api/host/akm/stats/+server.ts similarity index 88% rename from packages/ui/src/routes/admin/akm/stats/+server.ts rename to packages/ui/src/routes/api/host/akm/stats/+server.ts index d882451dd..3dfdc35a5 100644 --- a/packages/ui/src/routes/admin/akm/stats/+server.ts +++ b/packages/ui/src/routes/api/host/akm/stats/+server.ts @@ -1,7 +1,7 @@ import type { RequestHandler } from './$types'; import { getState } from '$lib/server/state.js'; import { getAkmStats } from '@openpalm/lib'; -import { errorResponse, getRequestId, jsonResponse, requireAdmin } from '$lib/server/helpers.js'; +import { errorResponse, getRequestId, jsonResponse, requireAdmin, requireCapability } from '$lib/server/helpers.js'; const CACHE_TTL_MS = 15_000; @@ -19,6 +19,8 @@ export function _resetStatsCacheForTests(): void { export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:containers', requestId); + if (capabilityError) return capabilityError; const denied = requireAdmin(event, requestId); if (denied) return denied; diff --git a/packages/ui/src/routes/admin/akm/stats/server.vitest.ts b/packages/ui/src/routes/api/host/akm/stats/server.vitest.ts similarity index 94% rename from packages/ui/src/routes/admin/akm/stats/server.vitest.ts rename to packages/ui/src/routes/api/host/akm/stats/server.vitest.ts index 1b7df77ba..0a191b558 100644 --- a/packages/ui/src/routes/admin/akm/stats/server.vitest.ts +++ b/packages/ui/src/routes/api/host/akm/stats/server.vitest.ts @@ -22,7 +22,7 @@ let rootDir = ''; let originalHome: string | undefined; function makeEvent(token = 'admin-token'): StatsRequestEvent { - const url = new URL('http://localhost/admin/akm/stats'); + const url = new URL('http://localhost/api/host/akm/stats'); return { request: new Request(url, { method: 'GET', @@ -37,6 +37,9 @@ function makeEvent(token = 'admin-token'): StatsRequestEvent { } beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; rootDir = join(tmpdir(), `openpalm-akm-stats-${randomBytes(4).toString('hex')}`); mkdirSync(rootDir, { recursive: true }); originalHome = process.env.OP_HOME; @@ -47,12 +50,13 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; process.env.OP_HOME = originalHome; rmSync(rootDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); -describe('GET /admin/akm/stats', () => { +describe('GET /api/host/akm/stats', () => { test('401 without auth', async () => { expect((await GET(makeEvent(''))).status).toBe(401); }); diff --git a/packages/ui/src/routes/admin/automations/+server.ts b/packages/ui/src/routes/api/host/automations/+server.ts similarity index 82% rename from packages/ui/src/routes/admin/automations/+server.ts rename to packages/ui/src/routes/api/host/automations/+server.ts index cc66e1858..9b79604f2 100644 --- a/packages/ui/src/routes/admin/automations/+server.ts +++ b/packages/ui/src/routes/api/host/automations/+server.ts @@ -1,5 +1,5 @@ /** - * GET /admin/automations — List automation configs from knowledge/tasks/. + * GET /api/host/automations — List automation configs from knowledge/tasks/. * * Read-only endpoint. Automations are AKM task files at * ${stashDir}/tasks/*.yml. The scheduler runs as a co-process inside the @@ -10,12 +10,15 @@ import { getState } from "$lib/server/state.js"; import { jsonResponse, requireAdmin, + requireCapability, getRequestId, } from "$lib/server/helpers.js"; import { loadAutomations } from "@openpalm/lib"; export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:stack:read', requestId); + if (capabilityError) return capabilityError; const authErr = requireAdmin(event, requestId); if (authErr) return authErr; diff --git a/packages/ui/src/routes/admin/automations/[name]/file/+server.ts b/packages/ui/src/routes/api/host/automations/[name]/file/+server.ts similarity index 78% rename from packages/ui/src/routes/admin/automations/[name]/file/+server.ts rename to packages/ui/src/routes/api/host/automations/[name]/file/+server.ts index eab208211..ced081bcf 100644 --- a/packages/ui/src/routes/admin/automations/[name]/file/+server.ts +++ b/packages/ui/src/routes/api/host/automations/[name]/file/+server.ts @@ -2,9 +2,9 @@ * Raw editor access to an akm task file in the assistant tasks dir * (/stash/tasks = knowledge/tasks), used by the Automations admin tab. * - * GET /admin/automations//file — read raw contents { name, content } - * PUT /admin/automations//file — write raw contents (body { content }) - * DELETE /admin/automations//file — delete the task file + * GET /api/host/automations//file — read raw contents { name, content } + * PUT /api/host/automations//file — write raw contents (body { content }) + * DELETE /api/host/automations//file — delete the task file * * `name` is a .yml/.yaml/.md basename; the lib guards against path traversal. */ @@ -18,6 +18,7 @@ import { parseJsonBody, jsonBodyError, requireAdmin, + requireCapability, } from '$lib/server/helpers.js'; function guard(name: string, requestId: string): Response | null { @@ -27,6 +28,8 @@ function guard(name: string, requestId: string): Response | null { export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:stack:read', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; const name = event.params.name; @@ -40,6 +43,8 @@ export const GET: RequestHandler = async (event) => { export const PUT: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:stack:write', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; const name = event.params.name; @@ -57,6 +62,8 @@ export const PUT: RequestHandler = async (event) => { export const DELETE: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:stack:write', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; const name = event.params.name; diff --git a/packages/ui/src/routes/admin/automations/[name]/log/+server.ts b/packages/ui/src/routes/api/host/automations/[name]/log/+server.ts similarity index 87% rename from packages/ui/src/routes/admin/automations/[name]/log/+server.ts rename to packages/ui/src/routes/api/host/automations/[name]/log/+server.ts index ac7eaaca1..135b5dc71 100644 --- a/packages/ui/src/routes/admin/automations/[name]/log/+server.ts +++ b/packages/ui/src/routes/api/host/automations/[name]/log/+server.ts @@ -1,5 +1,5 @@ /** - * GET /admin/automations/:name/log — Recent execution logs for an automation. + * GET /api/host/automations/:name/log — Recent execution logs for an automation. * * Reads per-run log files from the AKM task log directory: * ${dataDir}/akm/cache/tasks/logs//.log @@ -13,6 +13,7 @@ import { jsonResponse, errorResponse, requireAdmin, + requireCapability, getRequestId, } from "$lib/server/helpers.js"; import { readAutomationLogs } from "@openpalm/lib"; @@ -23,6 +24,8 @@ const MAX_LIMIT = 500; export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:logs', requestId); + if (capabilityError) return capabilityError; const authErr = requireAdmin(event, requestId); if (authErr) return authErr; diff --git a/packages/ui/src/routes/admin/automations/[name]/log/server.vitest.ts b/packages/ui/src/routes/api/host/automations/[name]/log/server.vitest.ts similarity index 93% rename from packages/ui/src/routes/admin/automations/[name]/log/server.vitest.ts rename to packages/ui/src/routes/api/host/automations/[name]/log/server.vitest.ts index a59f63ebf..59476cbe2 100644 --- a/packages/ui/src/routes/admin/automations/[name]/log/server.vitest.ts +++ b/packages/ui/src/routes/api/host/automations/[name]/log/server.vitest.ts @@ -18,7 +18,7 @@ function makeLogEvent( searchParams: Record = {}, token = 'admin-token', ): Parameters[0] { - const url = new URL(`http://localhost/admin/automations/${encodeURIComponent(name)}/log`); + const url = new URL(`http://localhost/api/host/automations/${encodeURIComponent(name)}/log`); for (const [k, v] of Object.entries(searchParams)) url.searchParams.set(k, v); return { request: new Request(url, { @@ -43,18 +43,22 @@ function seedTaskLogs(dataDir: string, id: string, entries: Array<{ ts: string; let originalHome: string | undefined; beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; originalHome = process.env.OP_HOME; process.env.OP_HOME = makeTempDir(); resetState('admin-token'); }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; process.env.OP_HOME = originalHome; cleanupTempDirs(); rmSync(getState().homeDir, { recursive: true, force: true }); }); -describe('GET /admin/automations/:name/log', () => { +describe('GET /api/host/automations/:name/log', () => { test('returns 401 when unauthenticated', async () => { const res = await GET(makeLogEvent('health-check', {}, 'bad-token')); expect(res.status).toBe(401); diff --git a/packages/ui/src/routes/admin/automations/[name]/run/+server.ts b/packages/ui/src/routes/api/host/automations/[name]/run/+server.ts similarity index 86% rename from packages/ui/src/routes/admin/automations/[name]/run/+server.ts rename to packages/ui/src/routes/api/host/automations/[name]/run/+server.ts index 615de3560..cb5a90c2d 100644 --- a/packages/ui/src/routes/admin/automations/[name]/run/+server.ts +++ b/packages/ui/src/routes/api/host/automations/[name]/run/+server.ts @@ -1,5 +1,5 @@ /** - * POST /admin/automations/:name/run — Manually trigger an automation. + * POST /api/host/automations/:name/run — Manually trigger an automation. * * Spawns `akm tasks run ` directly (no sentinel files). The task * must exist in ${stashDir}/tasks/.yml to be accepted. @@ -10,6 +10,7 @@ import { jsonResponse, errorResponse, requireAdmin, + requireCapability, getRequestId, } from "$lib/server/helpers.js"; import { @@ -22,6 +23,8 @@ const SAFE_NAME_RE = /^[a-zA-Z0-9._-]+(?:\.ya?ml)?$/; export const POST: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:stack:write', requestId); + if (capabilityError) return capabilityError; const authErr = requireAdmin(event, requestId); if (authErr) return authErr; diff --git a/packages/ui/src/routes/admin/automations/[name]/run/server.vitest.ts b/packages/ui/src/routes/api/host/automations/[name]/run/server.vitest.ts similarity index 90% rename from packages/ui/src/routes/admin/automations/[name]/run/server.vitest.ts rename to packages/ui/src/routes/api/host/automations/[name]/run/server.vitest.ts index a908a306d..1fab9974d 100644 --- a/packages/ui/src/routes/admin/automations/[name]/run/server.vitest.ts +++ b/packages/ui/src/routes/api/host/automations/[name]/run/server.vitest.ts @@ -26,7 +26,7 @@ function makeRunEvent( token = 'admin-token', ): Parameters[0] { return { - request: new Request(`http://localhost/admin/automations/${encodeURIComponent(name)}/run`, { + request: new Request(`http://localhost/api/host/automations/${encodeURIComponent(name)}/run`, { method: 'POST', headers: { cookie: `op_session=${token}`, @@ -49,6 +49,9 @@ function seedInstalledTask(stashDir: string, id: string): void { let originalHome: string | undefined; beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; originalHome = process.env.OP_HOME; process.env.OP_HOME = makeTempDir(); resetState('admin-token'); @@ -56,12 +59,13 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; process.env.OP_HOME = originalHome; cleanupTempDirs(); rmSync(getState().homeDir, { recursive: true, force: true }); }); -describe('POST /admin/automations/:name/run', () => { +describe('POST /api/host/automations/:name/run', () => { test('returns 401 when unauthenticated', async () => { seedInstalledTask(getState().stashDir, 'health-check'); const res = await POST(makeRunEvent('health-check', 'bad-token')); diff --git a/packages/ui/src/routes/admin/backups/+server.ts b/packages/ui/src/routes/api/host/backups/+server.ts similarity index 89% rename from packages/ui/src/routes/admin/backups/+server.ts rename to packages/ui/src/routes/api/host/backups/+server.ts index 10ac473aa..ca350d3a0 100644 --- a/packages/ui/src/routes/admin/backups/+server.ts +++ b/packages/ui/src/routes/api/host/backups/+server.ts @@ -3,6 +3,7 @@ import { jsonResponse, errorResponse, requireAdmin, + requireCapability, } from "$lib/server/helpers.js"; import { getState } from "$lib/server/state.js"; import { @@ -22,6 +23,8 @@ const logger = createLogger("backups-admin"); */ export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:recovery', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; @@ -42,6 +45,8 @@ export const GET: RequestHandler = async (event) => { */ export const POST: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:recovery', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/config/validate/+server.ts b/packages/ui/src/routes/api/host/config/validate/+server.ts similarity index 85% rename from packages/ui/src/routes/admin/config/validate/+server.ts rename to packages/ui/src/routes/api/host/config/validate/+server.ts index 23a0fbd47..b004897bd 100644 --- a/packages/ui/src/routes/admin/config/validate/+server.ts +++ b/packages/ui/src/routes/api/host/config/validate/+server.ts @@ -1,5 +1,5 @@ /** - * GET /admin/config/validate — Run environment validation. + * GET /api/host/config/validate — Run environment validation. * * Checks knowledge/env/stack.env and knowledge/secrets for required runtime * configuration and non-empty required tokens. No varlock — the in-house @@ -12,6 +12,7 @@ import { getState } from "$lib/server/state.js"; import { jsonResponse, requireAdmin, + requireCapability, getRequestId, } from "$lib/server/helpers.js"; import { validateProposedState, createLogger } from "@openpalm/lib"; @@ -20,6 +21,8 @@ const logger = createLogger("admin.config.validate"); export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:stack:read', requestId); + if (capabilityError) return capabilityError; const authErr = requireAdmin(event, requestId); if (authErr) return authErr; diff --git a/packages/ui/src/routes/admin/config/validate/server.vitest.ts b/packages/ui/src/routes/api/host/config/validate/server.vitest.ts similarity index 89% rename from packages/ui/src/routes/admin/config/validate/server.vitest.ts rename to packages/ui/src/routes/api/host/config/validate/server.vitest.ts index 83d0f13f9..2e2569201 100644 --- a/packages/ui/src/routes/admin/config/validate/server.vitest.ts +++ b/packages/ui/src/routes/api/host/config/validate/server.vitest.ts @@ -1,5 +1,5 @@ /** - * Tests for GET /admin/config/validate route. + * Tests for GET /api/host/config/validate route. */ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { join } from "node:path"; @@ -31,6 +31,9 @@ let rootDir = ""; let originalHome: string | undefined; beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; rootDir = makeTempDir(); originalHome = process.env.OP_HOME; process.env.OP_HOME = rootDir; @@ -43,6 +46,7 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; vi.resetAllMocks(); process.env.OP_HOME = originalHome; rmSync(rootDir, { recursive: true, force: true }); @@ -57,14 +61,14 @@ function makeGetEvent(token = "admin-token"): Parameters[0] { headers.cookie = `op_session=${token}`; } return { - request: new Request("http://localhost/admin/config/validate", { + request: new Request("http://localhost/api/host/config/validate", { method: "GET", headers }) } as Parameters[0]; } -describe("GET /admin/config/validate", () => { +describe("GET /api/host/config/validate", () => { test("returns 200 with { ok: true } when validation succeeds", async () => { vi.mocked(validateProposedState).mockResolvedValue({ ok: true, diff --git a/packages/ui/src/routes/admin/containers/down/+server.ts b/packages/ui/src/routes/api/host/containers/down/+server.ts similarity index 92% rename from packages/ui/src/routes/admin/containers/down/+server.ts rename to packages/ui/src/routes/api/host/containers/down/+server.ts index 869a17334..daac5085d 100644 --- a/packages/ui/src/routes/admin/containers/down/+server.ts +++ b/packages/ui/src/routes/api/host/containers/down/+server.ts @@ -3,6 +3,7 @@ import { jsonResponse, errorResponse, requireAdmin, + requireCapability, parseJsonBody, jsonBodyError } from "$lib/server/helpers.js"; @@ -16,6 +17,8 @@ const logger = createLogger("containers-down"); export const POST: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:containers', requestId); + if (capabilityError) return capabilityError; logger.info("container stop request", { requestId }); const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/containers/down/server.vitest.ts b/packages/ui/src/routes/api/host/containers/down/server.vitest.ts similarity index 88% rename from packages/ui/src/routes/admin/containers/down/server.vitest.ts rename to packages/ui/src/routes/api/host/containers/down/server.vitest.ts index fa3ce57b5..c694e26ae 100644 --- a/packages/ui/src/routes/admin/containers/down/server.vitest.ts +++ b/packages/ui/src/routes/api/host/containers/down/server.vitest.ts @@ -1,5 +1,5 @@ /** - * Thin route tests for POST /admin/containers/down (3.4 — mutating-endpoint coverage). + * Thin route tests for POST /api/host/containers/down (3.4 — mutating-endpoint coverage). * * Previously untested. Covers: auth gate, invalid service rejection, the * docker-unavailable soft-success path, the docker-success path, and a @@ -25,7 +25,7 @@ import { POST } from './+server.js'; function makePostEvent(token = 'admin-token', body: unknown = { service: 'assistant' }): Parameters[0] { return { - request: new Request('http://localhost/admin/containers/down', { + request: new Request('http://localhost/api/host/containers/down', { method: 'POST', headers: { cookie: `op_session=${token}`, 'content-type': 'application/json' }, body: JSON.stringify(body), @@ -34,6 +34,9 @@ function makePostEvent(token = 'admin-token', body: unknown = { service: 'assist } beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; resetState('admin-token'); composeStopMock.mockReset(); checkDockerMock.mockReset(); @@ -43,10 +46,11 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; vi.clearAllMocks(); }); -describe('POST /admin/containers/down', () => { +describe('POST /api/host/containers/down', () => { test('requires admin auth', async () => { const res = await POST(makePostEvent('bad-token')); expect(res.status).toBe(401); diff --git a/packages/ui/src/routes/admin/containers/events/+server.ts b/packages/ui/src/routes/api/host/containers/events/+server.ts similarity index 89% rename from packages/ui/src/routes/admin/containers/events/+server.ts rename to packages/ui/src/routes/api/host/containers/events/+server.ts index aff0b39a1..ac43aba56 100644 --- a/packages/ui/src/routes/admin/containers/events/+server.ts +++ b/packages/ui/src/routes/api/host/containers/events/+server.ts @@ -3,12 +3,15 @@ import { jsonResponse, errorResponse, requireAdmin, + requireCapability, } from "$lib/server/helpers.js"; import { getDockerEvents, checkDocker } from "@openpalm/lib"; import type { RequestHandler } from "./$types"; export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:containers', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/containers/list/+server.ts b/packages/ui/src/routes/api/host/containers/list/+server.ts similarity index 95% rename from packages/ui/src/routes/admin/containers/list/+server.ts rename to packages/ui/src/routes/api/host/containers/list/+server.ts index 4f667cb5f..636241eae 100644 --- a/packages/ui/src/routes/admin/containers/list/+server.ts +++ b/packages/ui/src/routes/api/host/containers/list/+server.ts @@ -2,6 +2,7 @@ import { getRequestId, jsonResponse, requireAdmin, + requireCapability, } from "$lib/server/helpers.js"; import { getState } from "$lib/server/state.js"; import { buildComposeOptions, buildManagedServices } from "@openpalm/lib"; @@ -10,6 +11,8 @@ import type { RequestHandler } from "./$types"; export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:containers', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/containers/list/server.vitest.ts b/packages/ui/src/routes/api/host/containers/list/server.vitest.ts similarity index 90% rename from packages/ui/src/routes/admin/containers/list/server.vitest.ts rename to packages/ui/src/routes/api/host/containers/list/server.vitest.ts index 6ae7e1c05..8d6e96b4b 100644 --- a/packages/ui/src/routes/admin/containers/list/server.vitest.ts +++ b/packages/ui/src/routes/api/host/containers/list/server.vitest.ts @@ -29,7 +29,7 @@ function makeTempDir(): string { function makeGetEvent(token = 'admin-token'): Parameters[0] { return { - request: new Request('http://localhost/admin/containers/list', { + request: new Request('http://localhost/api/host/containers/list', { headers: { cookie: `op_session=${token}`, 'x-request-id': 'req-containers-list', @@ -41,18 +41,22 @@ function makeGetEvent(token = 'admin-token'): Parameters[0] { let originalHome: string | undefined; beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; originalHome = process.env.OP_HOME; process.env.OP_HOME = makeTempDir(); resetState('admin-token'); }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; process.env.OP_HOME = originalHome; cleanupTempDirs(); rmSync(getState().homeDir, { recursive: true, force: true }); }); -describe('GET /admin/containers/list', () => { +describe('GET /api/host/containers/list', () => { test('requires admin auth', async () => { const res = await GET(makeGetEvent('bad-token')); expect(res.status).toBe(401); diff --git a/packages/ui/src/routes/admin/containers/pull/+server.ts b/packages/ui/src/routes/api/host/containers/pull/+server.ts similarity index 93% rename from packages/ui/src/routes/admin/containers/pull/+server.ts rename to packages/ui/src/routes/api/host/containers/pull/+server.ts index 5010d4245..b9e631b98 100644 --- a/packages/ui/src/routes/admin/containers/pull/+server.ts +++ b/packages/ui/src/routes/api/host/containers/pull/+server.ts @@ -3,6 +3,7 @@ import { jsonResponse, errorResponse, requireAdmin, + requireCapability, } from "$lib/server/helpers.js"; import { getState } from "$lib/server/state.js"; import { withSerialQueue } from "$lib/server/serial-queue.js"; @@ -14,6 +15,8 @@ const logger = createLogger("containers-pull"); export const POST: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:containers', requestId); + if (capabilityError) return capabilityError; logger.info("pull request received", { requestId }); const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/containers/pull/server.vitest.ts b/packages/ui/src/routes/api/host/containers/pull/server.vitest.ts similarity index 89% rename from packages/ui/src/routes/admin/containers/pull/server.vitest.ts rename to packages/ui/src/routes/api/host/containers/pull/server.vitest.ts index db84f8b8c..e6c101f03 100644 --- a/packages/ui/src/routes/admin/containers/pull/server.vitest.ts +++ b/packages/ui/src/routes/api/host/containers/pull/server.vitest.ts @@ -1,5 +1,5 @@ /** - * Thin route tests for POST /admin/containers/pull (3.4 — mutating-endpoint coverage). + * Thin route tests for POST /api/host/containers/pull (3.4 — mutating-endpoint coverage). * * Covers: auth gate, docker-unavailable 503, applyStack failure (502), and the * success path — the button routes through the single compose driver applyStack @@ -35,7 +35,7 @@ import { POST } from './+server.js'; function makePostEvent(token = 'admin-token'): Parameters[0] { return { - request: new Request('http://localhost/admin/containers/pull', { + request: new Request('http://localhost/api/host/containers/pull', { method: 'POST', headers: { cookie: `op_session=${token}`, 'content-type': 'application/json' }, body: '{}', @@ -44,6 +44,9 @@ function makePostEvent(token = 'admin-token'): Parameters[0] { } beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; resetState('admin-token'); applyStackMock.mockReset(); checkDockerMock.mockReset(); @@ -55,10 +58,11 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; vi.clearAllMocks(); }); -describe('POST /admin/containers/pull', () => { +describe('POST /api/host/containers/pull', () => { test('requires admin auth', async () => { const res = await POST(makePostEvent('bad-token')); expect(res.status).toBe(401); diff --git a/packages/ui/src/routes/admin/containers/restart/+server.ts b/packages/ui/src/routes/api/host/containers/restart/+server.ts similarity index 94% rename from packages/ui/src/routes/admin/containers/restart/+server.ts rename to packages/ui/src/routes/api/host/containers/restart/+server.ts index ab0a8dd3d..d64c21f6a 100644 --- a/packages/ui/src/routes/admin/containers/restart/+server.ts +++ b/packages/ui/src/routes/api/host/containers/restart/+server.ts @@ -3,6 +3,7 @@ import { jsonResponse, errorResponse, requireAdmin, + requireCapability, parseJsonBody, jsonBodyError } from "$lib/server/helpers.js"; @@ -16,6 +17,8 @@ const logger = createLogger("containers-restart"); export const POST: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:containers', requestId); + if (capabilityError) return capabilityError; logger.info("container restart request", { requestId }); const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/containers/restart/server.vitest.ts b/packages/ui/src/routes/api/host/containers/restart/server.vitest.ts similarity index 90% rename from packages/ui/src/routes/admin/containers/restart/server.vitest.ts rename to packages/ui/src/routes/api/host/containers/restart/server.vitest.ts index c27026c5e..6530e338b 100644 --- a/packages/ui/src/routes/admin/containers/restart/server.vitest.ts +++ b/packages/ui/src/routes/api/host/containers/restart/server.vitest.ts @@ -1,5 +1,5 @@ /** - * Thin route tests for POST /admin/containers/restart (3.4 — mutating-endpoint coverage). + * Thin route tests for POST /api/host/containers/restart (3.4 — mutating-endpoint coverage). * * Previously untested. Mirrors containers/up: auth gate, invalid service * rejection, docker-unavailable soft-success, docker success, docker error, @@ -28,7 +28,7 @@ import { POST } from './+server.js'; function makePostEvent(token = 'admin-token', body: unknown = { service: 'assistant' }): Parameters[0] { return { - request: new Request('http://localhost/admin/containers/restart', { + request: new Request('http://localhost/api/host/containers/restart', { method: 'POST', headers: { cookie: `op_session=${token}`, 'content-type': 'application/json' }, body: JSON.stringify(body), @@ -37,6 +37,9 @@ function makePostEvent(token = 'admin-token', body: unknown = { service: 'assist } beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; resetState('admin-token'); composeRestartMock.mockReset(); checkDockerMock.mockReset(); @@ -48,10 +51,11 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; vi.clearAllMocks(); }); -describe('POST /admin/containers/restart', () => { +describe('POST /api/host/containers/restart', () => { test('requires admin auth', async () => { const res = await POST(makePostEvent('bad-token')); expect(res.status).toBe(401); diff --git a/packages/ui/src/routes/admin/containers/stats/+server.ts b/packages/ui/src/routes/api/host/containers/stats/+server.ts similarity index 90% rename from packages/ui/src/routes/admin/containers/stats/+server.ts rename to packages/ui/src/routes/api/host/containers/stats/+server.ts index 061064def..8d5c90c16 100644 --- a/packages/ui/src/routes/admin/containers/stats/+server.ts +++ b/packages/ui/src/routes/api/host/containers/stats/+server.ts @@ -3,6 +3,7 @@ import { jsonResponse, errorResponse, requireAdmin, + requireCapability, } from "$lib/server/helpers.js"; import { getState } from "$lib/server/state.js"; import { buildComposeOptions } from "@openpalm/lib"; @@ -11,6 +12,8 @@ import type { RequestHandler } from "./$types"; export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:containers', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/containers/up/+server.ts b/packages/ui/src/routes/api/host/containers/up/+server.ts similarity index 94% rename from packages/ui/src/routes/admin/containers/up/+server.ts rename to packages/ui/src/routes/api/host/containers/up/+server.ts index 0f4f9f302..926c7634c 100644 --- a/packages/ui/src/routes/admin/containers/up/+server.ts +++ b/packages/ui/src/routes/api/host/containers/up/+server.ts @@ -3,6 +3,7 @@ import { jsonResponse, errorResponse, requireAdmin, + requireCapability, parseJsonBody, jsonBodyError } from "$lib/server/helpers.js"; @@ -15,6 +16,8 @@ const logger = createLogger("containers-up"); export const POST: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:containers', requestId); + if (capabilityError) return capabilityError; logger.info("container start request received", { requestId }); const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/containers/up/server.vitest.ts b/packages/ui/src/routes/api/host/containers/up/server.vitest.ts similarity index 90% rename from packages/ui/src/routes/admin/containers/up/server.vitest.ts rename to packages/ui/src/routes/api/host/containers/up/server.vitest.ts index 543a94874..053fc23c1 100644 --- a/packages/ui/src/routes/admin/containers/up/server.vitest.ts +++ b/packages/ui/src/routes/api/host/containers/up/server.vitest.ts @@ -1,5 +1,5 @@ /** - * Thin route tests for POST /admin/containers/up (3.4 — mutating-endpoint coverage). + * Thin route tests for POST /api/host/containers/up (3.4 — mutating-endpoint coverage). * * Previously untested. Covers: auth gate, invalid service rejection, the * docker-unavailable soft-success path, the docker-success path, a docker @@ -28,7 +28,7 @@ import { POST } from './+server.js'; function makePostEvent(token = 'admin-token', body: unknown = { service: 'assistant' }): Parameters[0] { return { - request: new Request('http://localhost/admin/containers/up', { + request: new Request('http://localhost/api/host/containers/up', { method: 'POST', headers: { cookie: `op_session=${token}`, 'content-type': 'application/json' }, body: JSON.stringify(body), @@ -37,6 +37,9 @@ function makePostEvent(token = 'admin-token', body: unknown = { service: 'assist } beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; resetState('admin-token'); composeStartMock.mockReset(); checkDockerMock.mockReset(); @@ -48,10 +51,11 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; vi.clearAllMocks(); }); -describe('POST /admin/containers/up', () => { +describe('POST /api/host/containers/up', () => { test('requires admin auth', async () => { const res = await POST(makePostEvent('bad-token')); expect(res.status).toBe(401); diff --git a/packages/ui/src/routes/api/host/guard-hygiene.vitest.ts b/packages/ui/src/routes/api/host/guard-hygiene.vitest.ts new file mode 100644 index 000000000..23ec2648a --- /dev/null +++ b/packages/ui/src/routes/api/host/guard-hygiene.vitest.ts @@ -0,0 +1,76 @@ +/** + * Phase 4 hygiene — every /api/host/* endpoint carries a server-side + * requireCapability() guard (plan ui-runtime-modes-plan.md Phase 4 step 3, + * §8.5: "APIs enforce capabilities server-side"). + * + * Source-level test, like features-admin-hygiene.vitest.ts: the invariant is + * that no privileged host endpoint can ever ship without the capability + * guard, so the walker enumerates routes/api/host/**+server.ts and asserts + * the literal `requireCapability(` call in each route module. A shared + * wrapper (cf. requireConnectionsManage) must still keep at least one + * spelled-out requireCapability call in every route file — that is the + * contract this suite ratifies, mirroring /api/connections. + * + * RED until Phase 4 lands: routes/api/host/ does not exist yet — the + * /admin/* JSON endpoints (~50 of them, minus the Phase 2 connections move + * and the session-lifecycle endpoints) move here. + * + * Partition constraints pinned alongside (GREEN today, must stay green): + * - Session lifecycle (auth login/logout/session) must NOT live under + * /api/host — login must stay reachable in every mode (a capability guard + * on login would lock assistant-container out of its own assistant + * settings). Its new home is pinned by lib/api/admin-paths-hygiene. + * - Connections stay at /api/connections (Phase 2 EXCEPT-clause) — no + * /api/host/endpoints resurrection. + */ +import { describe, expect, test } from 'vitest'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// This file lives at routes/api/host/guard-hygiene.vitest.ts — the walker +// roots are derived from its own location so the suite keeps working if the +// tree is relocated wholesale. +const API_HOST_DIR = fileURLToPath(new URL('./', import.meta.url)); +const ROUTES_DIR = fileURLToPath(new URL('../../', import.meta.url)); + +function serverRouteFiles(root: string): string[] { + if (!existsSync(root)) return []; + return (readdirSync(root, { recursive: true }) as string[]) + .filter((rel) => rel.endsWith('+server.ts')) + .map((rel) => join(root, rel)); +} + +describe('every /api/host/**/+server.ts calls requireCapability (plan Phase 4 step 3, §8.5)', () => { + test('the privileged /admin API surface moved under /api/host (>= 20 endpoints)', () => { + // Guards the walker against vacuous passes: while routes/api/host is + // missing or near-empty, the per-file assertion below proves nothing. + expect(serverRouteFiles(API_HOST_DIR).length).toBeGreaterThanOrEqual(20); + }); + + test('no /api/host endpoint ships without a requireCapability( call', () => { + const files = serverRouteFiles(API_HOST_DIR); + expect(files.length).toBeGreaterThanOrEqual(20); // no vacuous green + const offenders = files + .filter((file) => !/requireCapability\(/.test(readFileSync(file, 'utf-8'))) + .map((file) => file.slice(ROUTES_DIR.length)); + expect(offenders).toEqual([]); + }); + + test('host-level AKM key sharing lives under /api/host, not /api/assistant (AkmTab split)', () => { + expect(existsSync(join(API_HOST_DIR, 'akm', 'host-sharing', '+server.ts'))).toBe(true); + expect(existsSync(join(ROUTES_DIR, 'api', 'assistant', 'akm', 'host-sharing'))).toBe(false); + }); + + test('CONSTRAINT (green today): session lifecycle endpoints are not under /api/host', () => { + // requireCapability on login would 403 assistant-container before it + // could ever authenticate for /api/assistant/* — auth lives outside the + // capability-guarded namespaces. + expect(existsSync(join(API_HOST_DIR, 'auth'))).toBe(false); + }); + + test('CONSTRAINT (green today): connections stay at /api/connections (Phase 2 EXCEPT clause)', () => { + expect(existsSync(join(API_HOST_DIR, 'endpoints'))).toBe(false); + expect(existsSync(join(ROUTES_DIR, 'api', 'connections', '+server.ts'))).toBe(true); + }); +}); diff --git a/packages/ui/src/routes/admin/health/+server.ts b/packages/ui/src/routes/api/host/health/+server.ts similarity index 84% rename from packages/ui/src/routes/admin/health/+server.ts rename to packages/ui/src/routes/api/host/health/+server.ts index 2c09fc8a2..bd4221e4a 100644 --- a/packages/ui/src/routes/admin/health/+server.ts +++ b/packages/ui/src/routes/api/host/health/+server.ts @@ -1,5 +1,5 @@ /** - * GET /admin/health + * GET /api/host/health * * Returns the admin service status and whether the OpenCode (assistant) * server is reachable. Used as the session probe on the admin and chat pages. @@ -9,11 +9,13 @@ * caller decides how to surface assistant unavailability. */ import type { RequestHandler } from './$types'; -import { requireAdmin, jsonResponse, getRequestId } from '$lib/server/helpers.js'; +import { requireAdmin, requireCapability, jsonResponse, getRequestId } from '$lib/server/helpers.js'; import { getActiveEndpoint } from '$lib/server/endpoints.js'; export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:stack:read', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/health/server.vitest.ts b/packages/ui/src/routes/api/host/health/legacy-server.vitest.ts similarity index 92% rename from packages/ui/src/routes/admin/health/server.vitest.ts rename to packages/ui/src/routes/api/host/health/legacy-server.vitest.ts index c1a284ab7..bff877e2d 100644 --- a/packages/ui/src/routes/admin/health/server.vitest.ts +++ b/packages/ui/src/routes/api/host/health/legacy-server.vitest.ts @@ -1,5 +1,5 @@ /** - * Tests for GET /admin/health. + * Tests for GET /api/host/health. * * Asserts: * - 401 without auth @@ -32,7 +32,7 @@ import { GET } from './+server.js'; function makeEvent(token = 'admin-token') { return { - request: new Request('http://localhost/admin/health', { + request: new Request('http://localhost/api/host/health', { headers: { cookie: token ? `op_session=${token}` : '', 'x-request-id': 'req-health-1', @@ -45,6 +45,9 @@ let rootDir = ''; let originalHome: string | undefined; beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; rootDir = join(tmpdir(), `openpalm-health-test-${randomBytes(4).toString('hex')}`); mkdirSync(rootDir, { recursive: true }); originalHome = process.env.OP_HOME; @@ -53,12 +56,13 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; process.env.OP_HOME = originalHome; rmSync(rootDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); -describe('GET /admin/health', () => { +describe('GET /api/host/health', () => { test('returns 401 without auth', async () => { const res = await GET(makeEvent('')); expect(res.status).toBe(401); diff --git a/packages/ui/src/routes/api/host/health/server.vitest.ts b/packages/ui/src/routes/api/host/health/server.vitest.ts new file mode 100644 index 000000000..c5f919673 --- /dev/null +++ b/packages/ui/src/routes/api/host/health/server.vitest.ts @@ -0,0 +1,145 @@ +/** + * Tests for GET /api/host/health — the representative privileged host + * endpoint of the Phase 4 control-plane split (plan ui-runtime-modes-plan.md + * Phase 4 steps 1+3, §6.4 API namespace table). + * + * ALL RED until Phase 4 lands: routes/api/host/health/+server.ts does not + * exist yet (it is the mechanical move of routes/admin/health/+server.ts). + * The module is loaded through a computed-specifier dynamic import so + * svelte-check stays clean while the suite is red. + * + * Contract under test: + * - Every /api/host/* endpoint carries a SERVER-SIDE requireCapability() + * guard in addition to the requireAdmin cookie check (plan §8.5; + * hasCapability() in the browser is UX only). The guard is + * capability-based, not session-based: a VALID admin session in a mode + * whose serverCapabilities carry no host:* capability (assistant-container, + * pwa-static) is still refused with 403. + * - host-ui / electron-host expose the host:* capability set → 200. + * - requireAdmin still applies: no session cookie → 401 even in host modes. + */ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { mkdirSync } 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'; + +type RouteHandler = (event: unknown) => Response | Promise; +type HostHealthRouteModule = { GET: RouteHandler }; + +/** RED-state-safe loader (same pattern as the Phase 2 /api/connections suite). */ +async function loadRoute(): Promise { + const specifier = './+server.js'; + return (await import(/* @vite-ignore */ specifier)) as HostHealthRouteModule; +} + +function makeTempHome(): string { + const dir = join(tmpdir(), `openpalm-host-health-${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/host/health'); + return { + url, + request: new Request(url, { + headers: { + ...(token ? { cookie: `op_session=${token}` } : {}), + 'x-request-id': 'req-host-health', + }, + }), + params: {}, + locals: { role: token ? 'admin' : null }, + route: { id: '/api/host/health' }, + getClientAddress: () => '127.0.0.1', + isDataRequest: false, + isSubRequest: false, + }; +} + +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'); + // The health endpoint probes the active assistant endpoint — never let the + // test suite touch the network. + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + 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/host/health — host capability guard (plan Phase 4 step 3, §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 assistant-container 403 is the capability guard, not generic auth', 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'); + const body = (await res.json()) as Record; + expect(body.error).toBe('capability_not_available'); + }); + + test('403 in pwa-static mode even with a valid admin session', async () => { + process.env.OP_UI_HOST_MODE = 'pwa-static'; + const { GET } = await loadRoute(); + const res = await GET(makeGetEvent()); + expect(res.status).toBe(403); + }); + + test('200 in host-ui mode with a valid admin session', 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; + expect(body.ok).toBe(true); + }); + + test('200 in electron-host mode with a valid admin session', async () => { + process.env.OP_UI_HOST_MODE = 'electron-host'; + const { GET } = await loadRoute(); + const res = await GET(makeGetEvent()); + expect(res.status).toBe(200); + }); + + test('401 in host-ui mode without a session cookie (requireAdmin still enforced)', async () => { + process.env.OP_UI_HOST_MODE = 'host-ui'; + const { GET } = await loadRoute(); + const res = await GET(makeGetEvent('')); + expect(res.status).toBe(401); + }); +}); diff --git a/packages/ui/src/routes/admin/install/+server.ts b/packages/ui/src/routes/api/host/install/+server.ts similarity index 95% rename from packages/ui/src/routes/admin/install/+server.ts rename to packages/ui/src/routes/api/host/install/+server.ts index 94f61d059..1a883b7e2 100644 --- a/packages/ui/src/routes/admin/install/+server.ts +++ b/packages/ui/src/routes/api/host/install/+server.ts @@ -3,6 +3,7 @@ import { getRequestId, jsonResponse, requireAdmin, + requireCapability, } from "$lib/server/helpers.js"; import { getState } from "$lib/server/state.js"; import { withSerialQueue } from "$lib/server/serial-queue.js"; @@ -21,6 +22,8 @@ const logger = createLogger("install"); export const POST: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:setup', requestId); + if (capabilityError) return capabilityError; logger.info("install request received", { requestId }); const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/install/server.vitest.ts b/packages/ui/src/routes/api/host/install/server.vitest.ts similarity index 94% rename from packages/ui/src/routes/admin/install/server.vitest.ts rename to packages/ui/src/routes/api/host/install/server.vitest.ts index 3093e2f67..79cccfa08 100644 --- a/packages/ui/src/routes/admin/install/server.vitest.ts +++ b/packages/ui/src/routes/api/host/install/server.vitest.ts @@ -1,5 +1,5 @@ /** - * Route-level tests for POST /admin/install. + * Route-level tests for POST /api/host/install. * * Phase 3: the route is now a thin wrapper over applyInstall() (files) + * applyStack() (containers). Pull failure is FATAL (§6). These tests verify @@ -40,7 +40,7 @@ import { POST } from './+server.js'; function makePostEvent(token = 'admin-token'): Parameters[0] { return { - request: new Request('http://localhost/admin/install', { + request: new Request('http://localhost/api/host/install', { method: 'POST', headers: { cookie: `op_session=${token}`, @@ -53,6 +53,9 @@ function makePostEvent(token = 'admin-token'): Parameters[0] { } beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; resetState('admin-token'); applyInstallMock.mockReset(); applyStackMock.mockReset(); @@ -64,13 +67,14 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; vi.clearAllMocks(); // The lock-contention test writes a foreign-held .install.lock into the // (test-shared) dataDir; remove it so it can't wedge later tests. rmSync(join(getState().dataDir, '.install.lock'), { force: true }); }); -describe('POST /admin/install', () => { +describe('POST /api/host/install', () => { test('requires admin auth', async () => { const res = await POST(makePostEvent('bad-token')); expect(res.status).toBe(401); diff --git a/packages/ui/src/routes/admin/logs/+server.ts b/packages/ui/src/routes/api/host/logs/+server.ts similarity index 92% rename from packages/ui/src/routes/admin/logs/+server.ts rename to packages/ui/src/routes/api/host/logs/+server.ts index 25c2e48f0..699410541 100644 --- a/packages/ui/src/routes/admin/logs/+server.ts +++ b/packages/ui/src/routes/api/host/logs/+server.ts @@ -1,5 +1,5 @@ /** - * GET /admin/logs — Retrieve docker compose service logs. + * GET /api/host/logs — Retrieve docker compose service logs. */ import type { RequestHandler } from "./$types"; import { getState } from "$lib/server/state.js"; @@ -7,6 +7,7 @@ import { jsonResponse, errorResponse, requireAdmin, + requireCapability, getRequestId, } from "$lib/server/helpers.js"; import { buildComposeOptions, isAllowedService } from "@openpalm/lib"; @@ -14,6 +15,8 @@ import { composeLogs, checkDocker } from "@openpalm/lib"; export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:logs', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/opencode/providers/[id]/auth/+server.ts b/packages/ui/src/routes/api/host/opencode/providers/[id]/auth/+server.ts similarity index 93% rename from packages/ui/src/routes/admin/opencode/providers/[id]/auth/+server.ts rename to packages/ui/src/routes/api/host/opencode/providers/[id]/auth/+server.ts index fddae8330..ad505e220 100644 --- a/packages/ui/src/routes/admin/opencode/providers/[id]/auth/+server.ts +++ b/packages/ui/src/routes/api/host/opencode/providers/[id]/auth/+server.ts @@ -1,6 +1,7 @@ import type { RequestHandler } from './$types'; import { requireAdmin, + requireCapability, jsonResponse, errorResponse, getRequestId, @@ -43,6 +44,8 @@ function purgeExpiredSessions(): void { export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:secrets', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; @@ -84,6 +87,8 @@ export const GET: RequestHandler = async (event) => { export const POST: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:secrets', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; @@ -165,11 +170,13 @@ export const POST: RequestHandler = async (event) => { }; /** - * DELETE /admin/opencode/providers/:id/auth — Disconnect a provider by + * DELETE /api/host/opencode/providers/:id/auth — Disconnect a provider by * removing its credential from OpenCode's auth.json. */ export const DELETE: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:secrets', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/opencode/providers/[id]/auth/server.vitest.ts b/packages/ui/src/routes/api/host/opencode/providers/[id]/auth/server.vitest.ts similarity index 97% rename from packages/ui/src/routes/admin/opencode/providers/[id]/auth/server.vitest.ts rename to packages/ui/src/routes/api/host/opencode/providers/[id]/auth/server.vitest.ts index 9a14d0c07..b5d6636cf 100644 --- a/packages/ui/src/routes/admin/opencode/providers/[id]/auth/server.vitest.ts +++ b/packages/ui/src/routes/api/host/opencode/providers/[id]/auth/server.vitest.ts @@ -43,7 +43,7 @@ function makeEvent( }, ): Parameters[0] { const providerId = options?.providerId ?? 'openai'; - const url = new URL(`http://localhost/admin/opencode/providers/${providerId}/auth`); + const url = new URL(`http://localhost/api/host/opencode/providers/${providerId}/auth`); if (options?.search) { url.search = options.search; } @@ -64,6 +64,9 @@ function makeEvent( } beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; vi.useRealTimers(); rootDir = makeTempDir(); originalHome = process.env.OP_HOME; @@ -72,13 +75,14 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; vi.useRealTimers(); process.env.OP_HOME = originalHome; rmSync(rootDir, { recursive: true, force: true }); vi.clearAllMocks(); }); -describe('/admin/opencode/providers/[id]/auth route', () => { +describe('/api/host/opencode/providers/[id]/auth route', () => { // ── Auth ──────────────────────────────────────────────────────────── test('requires admin token', async () => { const res = await POST(makeEvent('POST', { diff --git a/packages/ui/src/routes/admin/opencode/providers/[id]/models/+server.ts b/packages/ui/src/routes/api/host/opencode/providers/[id]/models/+server.ts similarity index 79% rename from packages/ui/src/routes/admin/opencode/providers/[id]/models/+server.ts rename to packages/ui/src/routes/api/host/opencode/providers/[id]/models/+server.ts index 4f2b622b2..cd220316e 100644 --- a/packages/ui/src/routes/admin/opencode/providers/[id]/models/+server.ts +++ b/packages/ui/src/routes/api/host/opencode/providers/[id]/models/+server.ts @@ -1,9 +1,11 @@ import type { RequestHandler } from './$types'; -import { requireAdmin, jsonResponse, getRequestId, errorResponse, getOpenCodeClient } from '$lib/server/helpers.js'; +import { requireAdmin, requireCapability, jsonResponse, getRequestId, errorResponse, getOpenCodeClient } from '$lib/server/helpers.js'; import { sanitizeOpenCodeModels } from '$lib/opencode/provider-models.js'; export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:secrets', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/opencode/providers/[id]/models/server.vitest.ts b/packages/ui/src/routes/api/host/opencode/providers/[id]/models/server.vitest.ts similarity index 88% rename from packages/ui/src/routes/admin/opencode/providers/[id]/models/server.vitest.ts rename to packages/ui/src/routes/api/host/opencode/providers/[id]/models/server.vitest.ts index 49ea0d400..fbd7264d9 100644 --- a/packages/ui/src/routes/admin/opencode/providers/[id]/models/server.vitest.ts +++ b/packages/ui/src/routes/api/host/opencode/providers/[id]/models/server.vitest.ts @@ -28,7 +28,7 @@ let originalHome: string | undefined; function makeEvent(providerId = 'openai', token = 'admin-token'): Parameters[0] { return { params: { id: providerId }, - request: new Request(`http://localhost/admin/opencode/providers/${providerId}/models`, { + request: new Request(`http://localhost/api/host/opencode/providers/${providerId}/models`, { headers: { cookie: `op_session=${token}`, 'x-request-id': 'req-models', @@ -38,6 +38,9 @@ function makeEvent(providerId = 'openai', token = 'admin-token'): Parameters { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; rootDir = makeTempDir(); originalHome = process.env.OP_HOME; process.env.OP_HOME = rootDir; @@ -45,12 +48,13 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; process.env.OP_HOME = originalHome; rmSync(rootDir, { recursive: true, force: true }); vi.clearAllMocks(); }); -describe('/admin/opencode/providers/[id]/models route', () => { +describe('/api/host/opencode/providers/[id]/models route', () => { test('requires admin token', async () => { const res = await GET(makeEvent('openai', 'bad-token')); expect(res.status).toBe(401); diff --git a/packages/ui/src/routes/admin/providers/+server.ts b/packages/ui/src/routes/api/host/providers/+server.ts similarity index 63% rename from packages/ui/src/routes/admin/providers/+server.ts rename to packages/ui/src/routes/api/host/providers/+server.ts index 4256e9232..7a19c9933 100644 --- a/packages/ui/src/routes/admin/providers/+server.ts +++ b/packages/ui/src/routes/api/host/providers/+server.ts @@ -1,9 +1,11 @@ import type { RequestHandler } from './$types'; -import { requireAdmin, jsonResponse, getRequestId } from '$lib/server/helpers.js'; +import { requireAdmin, requireCapability, jsonResponse, getRequestId } from '$lib/server/helpers.js'; import { loadProviderPage } from '$lib/server/opencode/catalog.js'; export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:secrets', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/providers/[id]/+server.ts b/packages/ui/src/routes/api/host/providers/[id]/+server.ts similarity index 96% rename from packages/ui/src/routes/admin/providers/[id]/+server.ts rename to packages/ui/src/routes/api/host/providers/[id]/+server.ts index 20551288e..66866f344 100644 --- a/packages/ui/src/routes/admin/providers/[id]/+server.ts +++ b/packages/ui/src/routes/api/host/providers/[id]/+server.ts @@ -1,5 +1,5 @@ /** - * PATCH /admin/providers/:id — Single endpoint for all provider mutations. + * PATCH /api/host/providers/:id — Single endpoint for all provider mutations. * * Discriminated by `body.kind`: * "options" — save connection settings (baseURL, headers, timeout, …) @@ -7,11 +7,12 @@ * "register" — register a local-detected or custom OpenAI-compatible provider * * Auth: admin token required. - * OAuth credential saves go to /admin/opencode/providers/:id/auth (unchanged). + * OAuth credential saves go to /api/host/opencode/providers/:id/auth (unchanged). */ import type { RequestHandler } from './$types'; import { requireAdmin, + requireCapability, jsonResponse, errorResponse, getRequestId, @@ -52,6 +53,8 @@ const LOCAL_PROVIDER_LABELS: Record = { export const PATCH: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:secrets', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/providers/[id]/server.vitest.ts b/packages/ui/src/routes/api/host/providers/[id]/server.vitest.ts similarity index 89% rename from packages/ui/src/routes/admin/providers/[id]/server.vitest.ts rename to packages/ui/src/routes/api/host/providers/[id]/server.vitest.ts index 233860bf6..efa055b56 100644 --- a/packages/ui/src/routes/admin/providers/[id]/server.vitest.ts +++ b/packages/ui/src/routes/api/host/providers/[id]/server.vitest.ts @@ -31,7 +31,7 @@ import { PATCH } from './+server.js'; import { getState } from '$lib/server/state.js'; function makeEvent(body: unknown, providerId = 'custom-ai'): Parameters[0] { - const url = new URL(`http://localhost/admin/providers/${providerId}`); + const url = new URL(`http://localhost/api/host/providers/${providerId}`); return { params: { id: providerId }, request: new Request(url, { @@ -51,6 +51,9 @@ let rootDir = ''; let originalHome: string | undefined; beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; rootDir = join(tmpdir(), `openpalm-provider-patch-${randomBytes(4).toString('hex')}`); mkdirSync(rootDir, { recursive: true }); originalHome = process.env.OP_HOME; @@ -62,11 +65,12 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; process.env.OP_HOME = originalHome; rmSync(rootDir, { recursive: true, force: true }); }); -describe('PATCH /admin/providers/[id]', () => { +describe('PATCH /api/host/providers/[id]', () => { test('register-custom stores API keys only in OpenCode auth.json', async () => { const res = await PATCH(makeEvent({ kind: 'register-custom', diff --git a/packages/ui/src/routes/admin/providers/_helpers.ts b/packages/ui/src/routes/api/host/providers/_helpers.ts similarity index 100% rename from packages/ui/src/routes/admin/providers/_helpers.ts rename to packages/ui/src/routes/api/host/providers/_helpers.ts diff --git a/packages/ui/src/routes/admin/providers/assistant-clis/+server.ts b/packages/ui/src/routes/api/host/providers/assistant-clis/+server.ts similarity index 65% rename from packages/ui/src/routes/admin/providers/assistant-clis/+server.ts rename to packages/ui/src/routes/api/host/providers/assistant-clis/+server.ts index 911ce4d6d..8c62b3652 100644 --- a/packages/ui/src/routes/admin/providers/assistant-clis/+server.ts +++ b/packages/ui/src/routes/api/host/providers/assistant-clis/+server.ts @@ -1,10 +1,12 @@ import type { RequestHandler } from './$types'; -import { getRequestId, jsonResponse, requireAdmin } from '$lib/server/helpers.js'; +import { getRequestId, jsonResponse, requireAdmin, requireCapability } from '$lib/server/helpers.js'; import { getState } from '$lib/server/state.js'; import { listAssistantCliTools } from '@openpalm/lib'; export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:secrets', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/providers/assistant-clis/[toolId]/use-provider/+server.ts b/packages/ui/src/routes/api/host/providers/assistant-clis/[toolId]/use-provider/+server.ts similarity index 92% rename from packages/ui/src/routes/admin/providers/assistant-clis/[toolId]/use-provider/+server.ts rename to packages/ui/src/routes/api/host/providers/assistant-clis/[toolId]/use-provider/+server.ts index 524c2e3f6..f87b9bbc6 100644 --- a/packages/ui/src/routes/admin/providers/assistant-clis/[toolId]/use-provider/+server.ts +++ b/packages/ui/src/routes/api/host/providers/assistant-clis/[toolId]/use-provider/+server.ts @@ -7,6 +7,7 @@ import { jsonResponse, parseJsonBody, requireAdmin, + requireCapability, } from '$lib/server/helpers.js'; import { getState } from '$lib/server/state.js'; @@ -18,6 +19,8 @@ function isAssistantCliToolId(value: string): value is AssistantCliToolId { export const POST: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:secrets', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/providers/host-status/+server.ts b/packages/ui/src/routes/api/host/providers/host-status/+server.ts similarity index 76% rename from packages/ui/src/routes/admin/providers/host-status/+server.ts rename to packages/ui/src/routes/api/host/providers/host-status/+server.ts index a2208e2bb..b1aaaf3e2 100644 --- a/packages/ui/src/routes/admin/providers/host-status/+server.ts +++ b/packages/ui/src/routes/api/host/providers/host-status/+server.ts @@ -1,5 +1,5 @@ /** - * GET /admin/providers/host-status + * GET /api/host/providers/host-status * * Detects whether the host has an existing OpenCode installation and returns * provider + credential counts. Never returns credential values. @@ -7,11 +7,13 @@ * Auth: admin token required. */ import type { RequestHandler } from './$types'; -import { requireAdmin, jsonResponse, getRequestId } from '$lib/server/helpers.js'; +import { requireAdmin, requireCapability, jsonResponse, getRequestId } from '$lib/server/helpers.js'; import { detectHostOpenCode } from '@openpalm/lib'; export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:secrets', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/providers/host-status/server.vitest.ts b/packages/ui/src/routes/api/host/providers/host-status/server.vitest.ts similarity index 89% rename from packages/ui/src/routes/admin/providers/host-status/server.vitest.ts rename to packages/ui/src/routes/api/host/providers/host-status/server.vitest.ts index 8173b73cf..3ee03862c 100644 --- a/packages/ui/src/routes/admin/providers/host-status/server.vitest.ts +++ b/packages/ui/src/routes/api/host/providers/host-status/server.vitest.ts @@ -24,7 +24,7 @@ let rootDir = ''; let originalHome: string | undefined; function makeEvent(headers: Record = {}): Parameters[0] { - const url = new URL('http://localhost/admin/providers/host-status'); + const url = new URL('http://localhost/api/host/providers/host-status'); return { request: new Request(url, { method: 'GET', @@ -40,6 +40,9 @@ function makeEvent(headers: Record = {}): Parameters } beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; rootDir = join(tmpdir(), `openpalm-host-status-${randomBytes(4).toString('hex')}`); mkdirSync(rootDir, { recursive: true }); originalHome = process.env.OP_HOME; @@ -49,11 +52,12 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; process.env.OP_HOME = originalHome; rmSync(rootDir, { recursive: true, force: true }); }); -describe('GET /admin/providers/host-status', () => { +describe('GET /api/host/providers/host-status', () => { test('rejects unauthenticated requests', async () => { const res = await GET(makeEvent({ cookie: 'op_session=wrong-token' })); expect(res.status).toBe(401); diff --git a/packages/ui/src/routes/admin/providers/import-host/+server.ts b/packages/ui/src/routes/api/host/providers/import-host/+server.ts similarity index 96% rename from packages/ui/src/routes/admin/providers/import-host/+server.ts rename to packages/ui/src/routes/api/host/providers/import-host/+server.ts index d16a53d64..92b467e6d 100644 --- a/packages/ui/src/routes/admin/providers/import-host/+server.ts +++ b/packages/ui/src/routes/api/host/providers/import-host/+server.ts @@ -1,5 +1,5 @@ /** - * POST /admin/providers/import-host + * POST /api/host/providers/import-host * * Copies host OpenCode config + auth into OP_HOME, then pushes each * imported credential to the running OpenCode server so the providers @@ -24,6 +24,7 @@ import { existsSync, readFileSync } from 'node:fs'; import type { RequestHandler } from './$types'; import { requireAdmin, + requireCapability, jsonResponse, errorResponse, getRequestId, @@ -103,6 +104,8 @@ async function pushAuthToOpenCode(authPath: string): Promise<{ pushed: number; f export const POST: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:secrets', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/providers/import-host/server.vitest.ts b/packages/ui/src/routes/api/host/providers/import-host/server.vitest.ts similarity index 96% rename from packages/ui/src/routes/admin/providers/import-host/server.vitest.ts rename to packages/ui/src/routes/api/host/providers/import-host/server.vitest.ts index 26f8669f4..aafb59921 100644 --- a/packages/ui/src/routes/admin/providers/import-host/server.vitest.ts +++ b/packages/ui/src/routes/api/host/providers/import-host/server.vitest.ts @@ -42,7 +42,7 @@ function makeEvent( body?: unknown, headers: Record = {} ): Parameters[0] { - const url = new URL('http://localhost/admin/providers/import-host'); + const url = new URL('http://localhost/api/host/providers/import-host'); return { request: new Request(url, { method: 'POST', @@ -60,6 +60,9 @@ function makeEvent( } beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; rootDir = join(tmpdir(), `openpalm-import-host-${randomBytes(4).toString('hex')}`); mkdirSync(rootDir, { recursive: true }); originalHome = process.env.OP_HOME; @@ -86,11 +89,12 @@ function writeImportedAuth(auth: Record): string { } afterEach(() => { + delete process.env.OP_UI_HOST_MODE; process.env.OP_HOME = originalHome; rmSync(rootDir, { recursive: true, force: true }); }); -describe('POST /admin/providers/import-host', () => { +describe('POST /api/host/providers/import-host', () => { test('rejects unauthenticated requests', async () => { const res = await POST(makeEvent(undefined, { cookie: 'op_session=wrong-token' })); expect(res.status).toBe(401); diff --git a/packages/ui/src/routes/admin/providers/oauth/[providerId]/callback/+server.ts b/packages/ui/src/routes/api/host/providers/oauth/[providerId]/callback/+server.ts similarity index 81% rename from packages/ui/src/routes/admin/providers/oauth/[providerId]/callback/+server.ts rename to packages/ui/src/routes/api/host/providers/oauth/[providerId]/callback/+server.ts index b89091622..f37552744 100644 --- a/packages/ui/src/routes/admin/providers/oauth/[providerId]/callback/+server.ts +++ b/packages/ui/src/routes/api/host/providers/oauth/[providerId]/callback/+server.ts @@ -1,16 +1,18 @@ /** - * POST /admin/providers/oauth/:providerId/callback + * POST /api/host/providers/oauth/:providerId/callback * * Forwards an OAuth callback (auto-mode completion) to the running * assistant's OpenCode, which holds the OAuth methods map needed to * complete the exchange. */ import type { RequestHandler } from './$types'; -import { requireAdmin, getRequestId, errorResponse } from '$lib/server/helpers.js'; +import { requireAdmin, requireCapability, getRequestId, errorResponse } from '$lib/server/helpers.js'; import { opencodeFetch } from '$lib/server/opencode/http.js'; export const POST: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:secrets', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/providers/oauth/finish/+server.ts b/packages/ui/src/routes/api/host/providers/oauth/finish/+server.ts similarity index 75% rename from packages/ui/src/routes/admin/providers/oauth/finish/+server.ts rename to packages/ui/src/routes/api/host/providers/oauth/finish/+server.ts index ee8d933d2..bc3a66d7d 100644 --- a/packages/ui/src/routes/admin/providers/oauth/finish/+server.ts +++ b/packages/ui/src/routes/api/host/providers/oauth/finish/+server.ts @@ -1,15 +1,18 @@ import type { RequestHandler } from './$types'; -import { jsonResponse, withAdminBody } from '$lib/server/helpers.js'; +import { getRequestId, jsonResponse, requireCapability, withAdminBody } from '$lib/server/helpers.js'; import { opencodeFetch } from '$lib/server/opencode/http.js'; import { asStringOrEmpty } from '../../_helpers.js'; import type { ProviderActionResult } from '$lib/types/providers.js'; /** - * POST /admin/providers/oauth/finish — Complete an OAuth code-mode + * POST /api/host/providers/oauth/finish — Complete an OAuth code-mode * sign-in by exchanging the operator-pasted authorization code with the * assistant OpenCode instance. */ -export const POST: RequestHandler = (event) => withAdminBody(event, async ({ requestId, body }) => { +export const POST: RequestHandler = async (event) => { + const capabilityError = requireCapability(event, 'host:secrets', getRequestId(event)); + if (capabilityError) return capabilityError; + return withAdminBody(event, async ({ requestId, body }) => { try { const providerId = asStringOrEmpty(body.providerId); const methodIndex = Number(asStringOrEmpty(body.methodIndex)); @@ -37,3 +40,4 @@ export const POST: RequestHandler = (event) => withAdminBody(event, async ({ req return jsonResponse(200, { ok: false, message, selectedProviderId: undefined } satisfies ProviderActionResult, requestId); } }); +}; diff --git a/packages/ui/src/routes/admin/providers/oauth/finish/server.vitest.ts b/packages/ui/src/routes/api/host/providers/oauth/finish/server.vitest.ts similarity index 90% rename from packages/ui/src/routes/admin/providers/oauth/finish/server.vitest.ts rename to packages/ui/src/routes/api/host/providers/oauth/finish/server.vitest.ts index 85858848d..7534b772e 100644 --- a/packages/ui/src/routes/admin/providers/oauth/finish/server.vitest.ts +++ b/packages/ui/src/routes/api/host/providers/oauth/finish/server.vitest.ts @@ -29,7 +29,7 @@ let rootDir = ''; let originalHome: string | undefined; function makeEvent(body: unknown, headers: Record = {}): Parameters[0] { - const url = new URL('http://localhost/admin/providers/oauth/finish'); + const url = new URL('http://localhost/api/host/providers/oauth/finish'); return { request: new Request(url, { method: 'POST', @@ -47,6 +47,9 @@ function makeEvent(body: unknown, headers: Record = {}): Paramet } beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; rootDir = join(tmpdir(), `openpalm-prov-oauth-finish-${randomBytes(4).toString('hex')}`); mkdirSync(rootDir, { recursive: true }); originalHome = process.env.OP_HOME; @@ -56,11 +59,12 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; process.env.OP_HOME = originalHome; rmSync(rootDir, { recursive: true, force: true }); }); -describe('POST /admin/providers/oauth/finish', () => { +describe('POST /api/host/providers/oauth/finish', () => { test('rejects unauthenticated requests', async () => { const res = await POST(makeEvent({ providerId: 'p', methodIndex: '0', code: 'abc' }, { cookie: 'op_session=wrong-token' })); expect(res.status).toBe(401); diff --git a/packages/ui/src/routes/admin/providers/oauth/start/+server.ts b/packages/ui/src/routes/api/host/providers/oauth/start/+server.ts similarity index 80% rename from packages/ui/src/routes/admin/providers/oauth/start/+server.ts rename to packages/ui/src/routes/api/host/providers/oauth/start/+server.ts index 2d3a10387..ea7db79bb 100644 --- a/packages/ui/src/routes/admin/providers/oauth/start/+server.ts +++ b/packages/ui/src/routes/api/host/providers/oauth/start/+server.ts @@ -1,11 +1,11 @@ import type { RequestHandler } from './$types'; -import { jsonResponse, withAdminBody } from '$lib/server/helpers.js'; +import { getRequestId, jsonResponse, requireCapability, withAdminBody } from '$lib/server/helpers.js'; import { opencodeFetch } from '$lib/server/opencode/http.js'; import { asStringOrEmpty, extractInputs } from '../../_helpers.js'; import type { ProviderActionResult } from '$lib/types/providers.js'; /** - * POST /admin/providers/oauth/start — Begin an OpenCode-mediated OAuth + * POST /api/host/providers/oauth/start — Begin an OpenCode-mediated OAuth * sign-in for a provider. Returns the authorization URL and any extra * inputs the operator needs to confirm in the UI. * @@ -13,7 +13,10 @@ import type { ProviderActionResult } from '$lib/types/providers.js'; * OP_OPENCODE_URL, which holds the OAuth methods map needed to issue * the authorize request. */ -export const POST: RequestHandler = (event) => withAdminBody(event, async ({ requestId, body }) => { +export const POST: RequestHandler = async (event) => { + const capabilityError = requireCapability(event, 'host:secrets', getRequestId(event)); + if (capabilityError) return capabilityError; + return withAdminBody(event, async ({ requestId, body }) => { try { const providerId = asStringOrEmpty(body.providerId); const methodIndex = Number(asStringOrEmpty(body.methodIndex)); @@ -45,3 +48,4 @@ export const POST: RequestHandler = (event) => withAdminBody(event, async ({ req return jsonResponse(200, { ok: false, message, selectedProviderId: undefined } satisfies ProviderActionResult, requestId); } }); +}; diff --git a/packages/ui/src/routes/admin/providers/oauth/start/server.vitest.ts b/packages/ui/src/routes/api/host/providers/oauth/start/server.vitest.ts similarity index 92% rename from packages/ui/src/routes/admin/providers/oauth/start/server.vitest.ts rename to packages/ui/src/routes/api/host/providers/oauth/start/server.vitest.ts index 72ce313e8..8fe1ad692 100644 --- a/packages/ui/src/routes/admin/providers/oauth/start/server.vitest.ts +++ b/packages/ui/src/routes/api/host/providers/oauth/start/server.vitest.ts @@ -34,7 +34,7 @@ let rootDir = ''; let originalHome: string | undefined; function makeEvent(body: unknown, headers: Record = {}): Parameters[0] { - const url = new URL('http://localhost/admin/providers/oauth/start'); + const url = new URL('http://localhost/api/host/providers/oauth/start'); return { request: new Request(url, { method: 'POST', @@ -52,6 +52,9 @@ function makeEvent(body: unknown, headers: Record = {}): Paramet } beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; rootDir = join(tmpdir(), `openpalm-prov-oauth-start-${randomBytes(4).toString('hex')}`); mkdirSync(rootDir, { recursive: true }); originalHome = process.env.OP_HOME; @@ -61,11 +64,12 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; process.env.OP_HOME = originalHome; rmSync(rootDir, { recursive: true, force: true }); }); -describe('POST /admin/providers/oauth/start', () => { +describe('POST /api/host/providers/oauth/start', () => { test('rejects unauthenticated requests', async () => { const res = await POST(makeEvent({ providerId: 'p', methodIndex: '0' }, { cookie: 'op_session=wrong-token' })); expect(res.status).toBe(401); diff --git a/packages/ui/src/routes/admin/providers/server.vitest.ts b/packages/ui/src/routes/api/host/providers/server.vitest.ts similarity index 91% rename from packages/ui/src/routes/admin/providers/server.vitest.ts rename to packages/ui/src/routes/api/host/providers/server.vitest.ts index 54e7e169f..40ffcf3c0 100644 --- a/packages/ui/src/routes/admin/providers/server.vitest.ts +++ b/packages/ui/src/routes/api/host/providers/server.vitest.ts @@ -1,5 +1,5 @@ /** - * Tests for GET /admin/providers. + * Tests for GET /api/host/providers. * * Asserts: * - 401 without auth @@ -43,7 +43,7 @@ import { loadProviderPage } from '$lib/server/opencode/catalog.js'; function makeEvent(token = 'admin-token') { return { - request: new Request('http://localhost/admin/providers', { + request: new Request('http://localhost/api/host/providers', { headers: { cookie: token ? `op_session=${token}` : '', 'x-request-id': 'req-providers-1', @@ -56,6 +56,9 @@ let rootDir = ''; let originalHome: string | undefined; beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; rootDir = join(tmpdir(), `openpalm-providers-test-${randomBytes(4).toString('hex')}`); mkdirSync(rootDir, { recursive: true }); originalHome = process.env.OP_HOME; @@ -65,12 +68,13 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; process.env.OP_HOME = originalHome; rmSync(rootDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); -describe('GET /admin/providers', () => { +describe('GET /api/host/providers', () => { test('returns 401 without auth', async () => { const res = await GET(makeEvent('')); expect(res.status).toBe(401); diff --git a/packages/ui/src/routes/admin/secret-notice/+server.ts b/packages/ui/src/routes/api/host/secret-notice/+server.ts similarity index 85% rename from packages/ui/src/routes/admin/secret-notice/+server.ts rename to packages/ui/src/routes/api/host/secret-notice/+server.ts index 513c28a9f..621daf5ef 100644 --- a/packages/ui/src/routes/admin/secret-notice/+server.ts +++ b/packages/ui/src/routes/api/host/secret-notice/+server.ts @@ -3,6 +3,7 @@ import { jsonResponse, errorResponse, requireAdmin, + requireCapability, } from "$lib/server/helpers.js"; import { getState } from "$lib/server/state.js"; import { @@ -21,6 +22,8 @@ const logger = createLogger("secret-notice"); */ export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:secrets', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; @@ -37,6 +40,8 @@ export const GET: RequestHandler = async (event) => { /** Dismiss the one-time notice. */ export const DELETE: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:secrets', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/secrets/+server.ts b/packages/ui/src/routes/api/host/secrets/+server.ts similarity index 56% rename from packages/ui/src/routes/admin/secrets/+server.ts rename to packages/ui/src/routes/api/host/secrets/+server.ts index dcd951cc8..8763a7f9f 100644 --- a/packages/ui/src/routes/admin/secrets/+server.ts +++ b/packages/ui/src/routes/api/host/secrets/+server.ts @@ -1,15 +1,17 @@ /** - * GET /admin/secrets — list the files in the assistant secrets dir (/stash/secrets + * GET /api/host/secrets — list the files in the assistant secrets dir (/stash/secrets * = OP_HOME/knowledge/secrets). Returns names + byte sizes only, never values. - * Per-file read/write/delete is handled by /admin/secrets/[name]. + * Per-file read/write/delete is handled by /api/host/secrets/[name]. */ import type { RequestHandler } from './$types'; import { listSecretFiles } from '@openpalm/lib'; import { getState } from '$lib/server/state.js'; -import { getRequestId, jsonResponse, requireAdmin } from '$lib/server/helpers.js'; +import { getRequestId, jsonResponse, requireAdmin, requireCapability } from '$lib/server/helpers.js'; export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:secrets', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/secrets/[name]/+server.ts b/packages/ui/src/routes/api/host/secrets/[name]/+server.ts similarity index 79% rename from packages/ui/src/routes/admin/secrets/[name]/+server.ts rename to packages/ui/src/routes/api/host/secrets/[name]/+server.ts index 6e2a7f1e5..8178db063 100644 --- a/packages/ui/src/routes/admin/secrets/[name]/+server.ts +++ b/packages/ui/src/routes/api/host/secrets/[name]/+server.ts @@ -2,9 +2,9 @@ * Per-file access to the assistant secrets dir (/stash/secrets = * OP_HOME/knowledge/secrets), used by the Secrets admin tab as a plain file editor. * - * GET /admin/secrets/ — read the raw file contents { name, value } - * PUT /admin/secrets/ — write raw contents (body { value }) - * DELETE /admin/secrets/ — delete the file + * GET /api/host/secrets/ — read the raw file contents { name, value } + * PUT /api/host/secrets/ — write raw contents (body { value }) + * DELETE /api/host/secrets/ — delete the file * * `name` is a basename only; the lib guards against path traversal and writes 0600. */ @@ -18,6 +18,7 @@ import { parseJsonBody, jsonBodyError, requireAdmin, + requireCapability, } from '$lib/server/helpers.js'; function guardName(name: string, requestId: string): Response | null { @@ -31,6 +32,8 @@ function guardName(name: string, requestId: string): Response | null { export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:secrets', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; const name = event.params.name; @@ -44,6 +47,8 @@ export const GET: RequestHandler = async (event) => { export const PUT: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:secrets', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; const name = event.params.name; @@ -61,6 +66,8 @@ export const PUT: RequestHandler = async (event) => { export const DELETE: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:secrets', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; const name = event.params.name; diff --git a/packages/ui/src/routes/admin/secrets/user-env/+server.ts b/packages/ui/src/routes/api/host/secrets/user-env/+server.ts similarity index 87% rename from packages/ui/src/routes/admin/secrets/user-env/+server.ts rename to packages/ui/src/routes/api/host/secrets/user-env/+server.ts index 48f26a8dc..2000fb518 100644 --- a/packages/ui/src/routes/admin/secrets/user-env/+server.ts +++ b/packages/ui/src/routes/api/host/secrets/user-env/+server.ts @@ -1,5 +1,5 @@ /** - * /admin/secrets/user-env — read/write the shared akm user env (`env:user`). + * /api/host/secrets/user-env — read/write the shared akm user env (`env:user`). * * The user env file (`knowledge/env/user.env`) is the sole source of truth for * user-managed configuration secrets. OpenPalm owns the file directly: writes @@ -16,6 +16,7 @@ import { getRequestId, jsonResponse, requireAdmin, + requireCapability, withAdminBody, } from '$lib/server/helpers.js'; import { @@ -36,6 +37,8 @@ const KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; */ export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:secrets', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; @@ -57,8 +60,10 @@ export const GET: RequestHandler = async (event) => { * a process argv. The assistant sources the env file at startup, so a key * written here is visible to OpenCode after the next assistant restart. */ -export const POST: RequestHandler = (event) => - withAdminBody(event, async ({ requestId, body }) => { +export const POST: RequestHandler = async (event) => { + const capabilityError = requireCapability(event, 'host:secrets', getRequestId(event)); + if (capabilityError) return capabilityError; + return withAdminBody(event, async ({ requestId, body }) => { const state = getState(); const key = typeof body.key === 'string' ? body.key.trim() : ''; const value = typeof body.value === 'string' ? body.value : null; @@ -94,10 +99,13 @@ export const POST: RequestHandler = (event) => return jsonResponse(200, { ok: true, key }, requestId); }); +}; /** DELETE — remove a key from the user env file. */ export const DELETE: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:secrets', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/secrets/user-env/server.vitest.ts b/packages/ui/src/routes/api/host/secrets/user-env/server.vitest.ts similarity index 73% rename from packages/ui/src/routes/admin/secrets/user-env/server.vitest.ts rename to packages/ui/src/routes/api/host/secrets/user-env/server.vitest.ts index 5341e3438..04a1cb644 100644 --- a/packages/ui/src/routes/admin/secrets/user-env/server.vitest.ts +++ b/packages/ui/src/routes/api/host/secrets/user-env/server.vitest.ts @@ -1,5 +1,5 @@ /** - * Tests for the /admin/secrets/user-env route. + * Tests for the /api/host/secrets/user-env route. * * The route operates on the akm `env:user` file (`knowledge/env/user.env`) * directly. akm (>= 0.8.0) no longer manages individual env entries, so the @@ -42,6 +42,9 @@ let rootDir = ''; let originalHome: string | undefined; beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; rootDir = makeTempDir(); originalHome = process.env.OP_HOME; process.env.OP_HOME = rootDir; @@ -54,21 +57,22 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; process.env.OP_HOME = originalHome; rmSync(rootDir, { recursive: true, force: true }); }); describe('admin user-env route', () => { test('GET returns 401 without admin token', async () => { - const res = await GET(makeEvent('GET', '/admin/secrets/user-env', undefined, '')); + const res = await GET(makeEvent('GET', '/api/host/secrets/user-env', undefined, '')); expect(res.status).toBe(401); }); test('GET lists user env keys without exposing values', async () => { - await POST(makeEvent('POST', '/admin/secrets/user-env', { key: 'CUSTOM_KEY', value: 'v1' })); - await POST(makeEvent('POST', '/admin/secrets/user-env', { key: 'OTHER_KEY', value: 'v2' })); + await POST(makeEvent('POST', '/api/host/secrets/user-env', { key: 'CUSTOM_KEY', value: 'v1' })); + await POST(makeEvent('POST', '/api/host/secrets/user-env', { key: 'OTHER_KEY', value: 'v2' })); - const res = await GET(makeEvent('GET', '/admin/secrets/user-env')); + const res = await GET(makeEvent('GET', '/api/host/secrets/user-env')); expect(res.status).toBe(200); const body = await res.json() as { keys: string[]; envRef: string; provider: string }; expect(body.provider).toBe('akm'); @@ -82,7 +86,7 @@ describe('admin user-env route', () => { }); test('POST writes a key to the user env file', async () => { - const res = await POST(makeEvent('POST', '/admin/secrets/user-env', { + const res = await POST(makeEvent('POST', '/api/host/secrets/user-env', { key: 'CUSTOM_TOKEN', value: 'secret-payload', })); @@ -96,7 +100,7 @@ describe('admin user-env route', () => { }); test('POST rejects invalid key', async () => { - const res = await POST(makeEvent('POST', '/admin/secrets/user-env', { + const res = await POST(makeEvent('POST', '/api/host/secrets/user-env', { key: 'bad key with spaces', value: 'whatever', })); @@ -104,7 +108,7 @@ describe('admin user-env route', () => { }); test('POST rejects empty value', async () => { - const res = await POST(makeEvent('POST', '/admin/secrets/user-env', { + const res = await POST(makeEvent('POST', '/api/host/secrets/user-env', { key: 'KEY', value: '', })); @@ -112,7 +116,7 @@ describe('admin user-env route', () => { }); test('POST rejects a value containing a newline (would corrupt the .env)', async () => { - const res = await POST(makeEvent('POST', '/admin/secrets/user-env', { + const res = await POST(makeEvent('POST', '/api/host/secrets/user-env', { key: 'MULTILINE', value: 'line1\nline2', })); @@ -120,10 +124,10 @@ describe('admin user-env route', () => { }); test('DELETE removes a key from the user env entirely', async () => { - await POST(makeEvent('POST', '/admin/secrets/user-env', { key: 'KEEP_ME', value: 'ok' })); - await POST(makeEvent('POST', '/admin/secrets/user-env', { key: 'DROP_ME', value: 'bye' })); + await POST(makeEvent('POST', '/api/host/secrets/user-env', { key: 'KEEP_ME', value: 'ok' })); + await POST(makeEvent('POST', '/api/host/secrets/user-env', { key: 'DROP_ME', value: 'bye' })); - const res = await DELETE(makeEvent('DELETE', '/admin/secrets/user-env?key=DROP_ME')); + const res = await DELETE(makeEvent('DELETE', '/api/host/secrets/user-env?key=DROP_ME')); expect(res.status).toBe(200); const state = getState(); @@ -133,18 +137,18 @@ describe('admin user-env route', () => { }); test('DELETE followed by GET no longer lists the key', async () => { - await POST(makeEvent('POST', '/admin/secrets/user-env', { key: 'KEEP_ME', value: 'ok' })); - await POST(makeEvent('POST', '/admin/secrets/user-env', { key: 'DROP_ME', value: 'bye' })); - await DELETE(makeEvent('DELETE', '/admin/secrets/user-env?key=DROP_ME')); + await POST(makeEvent('POST', '/api/host/secrets/user-env', { key: 'KEEP_ME', value: 'ok' })); + await POST(makeEvent('POST', '/api/host/secrets/user-env', { key: 'DROP_ME', value: 'bye' })); + await DELETE(makeEvent('DELETE', '/api/host/secrets/user-env?key=DROP_ME')); - const listRes = await GET(makeEvent('GET', '/admin/secrets/user-env')); + const listRes = await GET(makeEvent('GET', '/api/host/secrets/user-env')); const body = await listRes.json() as { keys: string[] }; expect(body.keys).toContain('KEEP_ME'); expect(body.keys).not.toContain('DROP_ME'); }); test('written user env file is mode 0600', async () => { - await POST(makeEvent('POST', '/admin/secrets/user-env', { key: 'TOKEN', value: 'x' })); + await POST(makeEvent('POST', '/api/host/secrets/user-env', { key: 'TOKEN', value: 'x' })); const state = getState(); const path = userEnvPathSync(state); // Sanity: the file exists and is readable for the assertion in the lib test. diff --git a/packages/ui/src/routes/admin/assistant/+server.ts b/packages/ui/src/routes/api/host/stack/+server.ts similarity index 68% rename from packages/ui/src/routes/admin/assistant/+server.ts rename to packages/ui/src/routes/api/host/stack/+server.ts index 270ce0c67..280447395 100644 --- a/packages/ui/src/routes/admin/assistant/+server.ts +++ b/packages/ui/src/routes/api/host/stack/+server.ts @@ -1,13 +1,25 @@ -import { existsSync, mkdirSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; +/** + * GET/PUT /api/host/stack — host stack settings (plan ui-runtime-modes-plan.md + * Phase 4 step 2, §5.F, §6.4). + * + * The HOST-SCOPED half of the old /admin/assistant endpoint: compose project + * name (OP_PROJECT_NAME) and assistant bind address (OP_ASSISTANT_BIND_ADDRESS, + * surfaced as `lanExposureEnabled`). Persona is assistant-owned and lives at + * /api/assistant/persona — it is deliberately absent from this payload. + * + * Guarded by the host:stack capabilities in addition to requireAdmin (plan + * §8.5): a valid admin session in a mode without host:* (assistant-container, + * pwa-static) is still refused with 403. + */ import type { RequestHandler } from './$types'; -import { patchSecretsEnvFile, readStackEnv, recordProjectRename, writeFileAtomic } from '@openpalm/lib'; +import { patchSecretsEnvFile, readStackEnv, recordProjectRename } from '@openpalm/lib'; import { getState } from '$lib/server/state.js'; import { errorResponse, getRequestId, jsonResponse, requireAdmin, + requireCapability, withAdminBody, } from '$lib/server/helpers.js'; @@ -16,20 +28,6 @@ const DEFAULT_ASSISTANT_BIND_ADDRESS = '127.0.0.1'; const LAN_ASSISTANT_BIND_ADDRESS = '0.0.0.0'; const PROJECT_NAME_RE = /^[a-z0-9][a-z0-9_-]*$/; -function personaPath(configDir: string): string { - return join(configDir, 'assistant', 'persona.md'); -} - -function readPersona(configDir: string): string { - const path = personaPath(configDir); - if (!existsSync(path)) return ''; - try { - return readFileSync(path, 'utf-8'); - } catch { - return ''; - } -} - function normalizeProjectName(raw: unknown): string | null { if (typeof raw !== 'string') return null; const value = raw.trim() || DEFAULT_PROJECT_NAME; @@ -39,6 +37,8 @@ function normalizeProjectName(raw: unknown): string | null { export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:stack:read', requestId); + if (capabilityError) return capabilityError; const denied = requireAdmin(event, requestId); if (denied) return denied; @@ -51,15 +51,17 @@ export const GET: RequestHandler = async (event) => { projectName: env.OP_PROJECT_NAME?.trim() || DEFAULT_PROJECT_NAME, lanExposureEnabled: (env.OP_ASSISTANT_BIND_ADDRESS?.trim() || DEFAULT_ASSISTANT_BIND_ADDRESS) === LAN_ASSISTANT_BIND_ADDRESS, stackEnvPath: 'knowledge/env/stack.env', - personaPath: 'config/assistant/persona.md', - personaContent: readPersona(state.configDir), }, requestId, ); }; -export const PUT: RequestHandler = async (event) => - withAdminBody(event, async ({ requestId, body }) => { +export const PUT: RequestHandler = async (event) => { + const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:stack:write', requestId); + if (capabilityError) return capabilityError; + + return withAdminBody(event, async ({ requestId, body }) => { const projectName = normalizeProjectName(body.projectName); if (!projectName) { return errorResponse( @@ -71,10 +73,6 @@ export const PUT: RequestHandler = async (event) => ); } - if (typeof body.personaContent !== 'string') { - return errorResponse(400, 'bad_request', 'personaContent must be a string', {}, requestId); - } - if (typeof body.lanExposureEnabled !== 'boolean') { return errorResponse(400, 'bad_request', 'lanExposureEnabled must be a boolean', {}, requestId); } @@ -94,13 +92,6 @@ export const PUT: RequestHandler = async (event) => recordProjectRename(state.homeDir, previousProjectName, projectName); } - const path = personaPath(state.configDir); - mkdirSync(join(state.configDir, 'assistant'), { recursive: true }); - const personaContent = body.personaContent.endsWith('\n') - ? body.personaContent - : `${body.personaContent}\n`; - writeFileAtomic(path, personaContent, 0o644); - return jsonResponse( 200, { @@ -109,9 +100,8 @@ export const PUT: RequestHandler = async (event) => projectRenamed, lanExposureEnabled: body.lanExposureEnabled, stackEnvPath: 'knowledge/env/stack.env', - personaPath: 'config/assistant/persona.md', - personaContent, }, requestId, ); }); +}; diff --git a/packages/ui/src/routes/admin/assistant/server.vitest.ts b/packages/ui/src/routes/api/host/stack/rename.vitest.ts similarity index 66% rename from packages/ui/src/routes/admin/assistant/server.vitest.ts rename to packages/ui/src/routes/api/host/stack/rename.vitest.ts index 34e0eae05..01d006923 100644 --- a/packages/ui/src/routes/admin/assistant/server.vitest.ts +++ b/packages/ui/src/routes/api/host/stack/rename.vitest.ts @@ -1,3 +1,10 @@ +/** + * Behavior carried over from the old /admin/assistant suite (Phase 4 moved + * the host-scoped half to /api/host/stack): stack.env writes, the #540 + * project-rename marker, and LAN-exposure toggling. Persona is no longer part + * of this endpoint — see routes/api/assistant/persona. The Phase 4 red suite + * (server.vitest.ts alongside) pins the capability guard + payload split. + */ import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { randomBytes } from 'node:crypto'; @@ -9,14 +16,14 @@ import { GET, PUT } from './+server.js'; let rootDir = ''; let originalHome: string | undefined; -function makeGetEvent(path = '/admin/assistant', token = 'admin-token') { +function makeGetEvent(path = '/api/host/stack', token = 'admin-token') { const url = new URL(`http://localhost${path}`); return { request: new Request(url, { method: 'GET', headers: { cookie: token ? `op_session=${token}` : '', - 'x-request-id': 'req-assistant-get', + 'x-request-id': 'req-host-stack-get', }, }), url, @@ -25,13 +32,13 @@ function makeGetEvent(path = '/admin/assistant', token = 'admin-token') { } function makePutEvent(body: unknown, token = 'admin-token') { - const url = new URL('http://localhost/admin/assistant'); + const url = new URL('http://localhost/api/host/stack'); return { request: new Request(url, { method: 'PUT', headers: { cookie: token ? `op_session=${token}` : '', - 'x-request-id': 'req-assistant-put', + 'x-request-id': 'req-host-stack-put', 'content-type': 'application/json', }, body: JSON.stringify(body), @@ -42,7 +49,9 @@ function makePutEvent(body: unknown, token = 'admin-token') { } beforeEach(() => { - rootDir = join(tmpdir(), `openpalm-assistant-${randomBytes(4).toString('hex')}`); + // Phase 4: /api/host endpoints are capability-guarded; run as a host mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; + rootDir = join(tmpdir(), `openpalm-host-stack-${randomBytes(4).toString('hex')}`); mkdirSync(rootDir, { recursive: true }); originalHome = process.env.OP_HOME; process.env.OP_HOME = rootDir; @@ -50,29 +59,23 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; process.env.OP_HOME = originalHome; rmSync(rootDir, { recursive: true, force: true }); }); -describe('GET /admin/assistant', () => { +describe('GET /api/host/stack', () => { test('401 without auth', async () => { - expect((await GET(makeGetEvent('/admin/assistant', ''))).status).toBe(401); + expect((await GET(makeGetEvent('/api/host/stack', ''))).status).toBe(401); }); - test('returns default project name and persona content', async () => { - const personaDir = join(rootDir, 'config', 'assistant'); - mkdirSync(personaDir, { recursive: true }); - const personaPath = join(personaDir, 'persona.md'); - const content = '# Persona\n'; - writeFileSync(personaPath, content); - + test('returns the default project name', async () => { const res = await GET(makeGetEvent()); expect(res.status).toBe(200); - const body = await res.json() as Record; + const body = await res.json() as Record; expect(body.projectName).toBe('openpalm'); expect(body.lanExposureEnabled).toBe(false); - expect(body.personaContent).toBe(content); - expect(body.personaPath).toBe('config/assistant/persona.md'); + expect(body.stackEnvPath).toBe('knowledge/env/stack.env'); }); test('returns enabled LAN exposure when stack.env binds assistant to all interfaces', async () => { @@ -86,31 +89,19 @@ describe('GET /admin/assistant', () => { }); }); -describe('PUT /admin/assistant', () => { +describe('PUT /api/host/stack', () => { test('rejects invalid project name', async () => { - const res = await PUT(makePutEvent({ projectName: 'Open Palm', lanExposureEnabled: false, personaContent: 'x' })); + const res = await PUT(makePutEvent({ projectName: 'Open Palm', lanExposureEnabled: false })); expect(res.status).toBe(400); }); test('rejects invalid LAN exposure toggle values', async () => { - const res = await PUT(makePutEvent({ projectName: 'openpalm', lanExposureEnabled: 'yes', personaContent: 'x' })); + const res = await PUT(makePutEvent({ projectName: 'openpalm', lanExposureEnabled: 'yes' })); expect(res.status).toBe(400); }); - test('writes project name to stack.env and persona to assistant config', async () => { - const res = await PUT(makePutEvent({ projectName: 'openpalm-dev', lanExposureEnabled: true, personaContent: '# Updated persona' })); - expect(res.status).toBe(200); - - const stackEnv = readFileSync(stackEnvFor(rootDir), 'utf-8'); - expect(stackEnv).toContain('OP_PROJECT_NAME=openpalm-dev'); - expect(stackEnv).toContain('OP_ASSISTANT_BIND_ADDRESS=0.0.0.0'); - - const personaPath = join(rootDir, 'config', 'assistant', 'persona.md'); - expect(readFileSync(personaPath, 'utf-8')).toBe('# Updated persona\n'); - }); - test('records a project rename so the next apply can tear down the old project (#540)', async () => { - const res = await PUT(makePutEvent({ projectName: 'my-agent', lanExposureEnabled: false, personaContent: '# P' })); + const res = await PUT(makePutEvent({ projectName: 'my-agent', lanExposureEnabled: false })); expect(res.status).toBe(200); const body = await res.json() as Record; expect(body.projectRenamed).toBe(true); @@ -120,7 +111,7 @@ describe('PUT /admin/assistant', () => { }); test('does not record a rename when the project name is unchanged', async () => { - const res = await PUT(makePutEvent({ projectName: 'openpalm', lanExposureEnabled: false, personaContent: '# P' })); + const res = await PUT(makePutEvent({ projectName: 'openpalm', lanExposureEnabled: false })); expect(res.status).toBe(200); const body = await res.json() as Record; expect(body.projectRenamed).toBe(false); @@ -128,8 +119,8 @@ describe('PUT /admin/assistant', () => { }); test('renaming back to the still-running project clears the recorded marker (#540)', async () => { - await PUT(makePutEvent({ projectName: 'my-agent', lanExposureEnabled: false, personaContent: '# P' })); - await PUT(makePutEvent({ projectName: 'openpalm', lanExposureEnabled: false, personaContent: '# P' })); + await PUT(makePutEvent({ projectName: 'my-agent', lanExposureEnabled: false })); + await PUT(makePutEvent({ projectName: 'openpalm', lanExposureEnabled: false })); const stateEnv = readFileSync(join(rootDir, 'state', 'stack.state.env'), 'utf-8'); expect(stateEnv).toContain('OP_PREVIOUS_PROJECT_NAME=\n'); @@ -138,7 +129,7 @@ describe('PUT /admin/assistant', () => { test('disables LAN exposure by restoring loopback bind address', async () => { writeFileSync(stackEnvFor(rootDir), 'OP_ASSISTANT_BIND_ADDRESS=0.0.0.0\n'); - const res = await PUT(makePutEvent({ projectName: 'openpalm', lanExposureEnabled: false, personaContent: '# Persona' })); + const res = await PUT(makePutEvent({ projectName: 'openpalm', lanExposureEnabled: false })); expect(res.status).toBe(200); const stackEnv = readFileSync(stackEnvFor(rootDir), 'utf-8'); diff --git a/packages/ui/src/routes/api/host/stack/server.vitest.ts b/packages/ui/src/routes/api/host/stack/server.vitest.ts new file mode 100644 index 000000000..2d0277777 --- /dev/null +++ b/packages/ui/src/routes/api/host/stack/server.vitest.ts @@ -0,0 +1,183 @@ +/** + * Tests for /api/host/stack — the HOST-SCOPED half of the old + * /admin/assistant endpoint (plan ui-runtime-modes-plan.md Phase 4 step 2, + * §5.F, §6.4). + * + * ALL RED until Phase 4 lands: routes/api/host/stack/+server.ts does not + * exist yet. Loaded via computed-specifier dynamic import so svelte-check + * stays clean while red. + * + * Contract under test — the AssistantTab split (plan §9 "Assistant settings"): + * - Project name (OP_PROJECT_NAME) and assistant bind address + * (OP_ASSISTANT_BIND_ADDRESS, surfaced as lanExposureEnabled) are HOST + * STACK settings → they live at GET/PUT /api/host/stack, guarded by the + * host:* capability set (host:stack:write for writes) + requireAdmin. + * - Persona is NOT part of this payload anymore — it is assistant-owned and + * moves to /api/assistant/* (see routes/api/assistant/persona tests). + * PUT therefore no longer requires personaContent. + * - Phase 4 acceptance: assistant-container can edit persona/AKM but NOT + * project name or bind address → 403 here even with a valid admin + * session, and stack.env stays untouched. + */ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { cleanupTempDirs, resetState, stackEnvFor, trackDir } from '$lib/server/test-helpers.js'; + +type RouteHandler = (event: unknown) => Response | Promise; +type HostStackRouteModule = { GET: RouteHandler; PUT: RouteHandler }; + +/** RED-state-safe loader (same pattern as the Phase 2 /api/connections suite). */ +async function loadRoute(): Promise { + const specifier = './+server.js'; + return (await import(/* @vite-ignore */ specifier)) as HostStackRouteModule; +} + +let homeDir = ''; + +function makeTempHome(): string { + const dir = join(tmpdir(), `openpalm-host-stack-${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/host/stack'); + return { + url, + request: new Request(url, { + headers: { + ...(token ? { cookie: `op_session=${token}` } : {}), + 'x-request-id': 'req-host-stack-get', + }, + }), + params: {}, + locals: { role: token ? 'admin' : null }, + route: { id: '/api/host/stack' }, + getClientAddress: () => '127.0.0.1', + isDataRequest: false, + isSubRequest: false, + }; +} + +function makePutEvent(body: Record, token = 'admin-token'): unknown { + const url = new URL('http://127.0.0.1:3880/api/host/stack'); + return { + url, + request: new Request(url, { + method: 'PUT', + headers: { + ...(token ? { cookie: `op_session=${token}` } : {}), + 'x-request-id': 'req-host-stack-put', + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }), + params: {}, + locals: { role: token ? 'admin' : null }, + route: { id: '/api/host/stack' }, + getClientAddress: () => '127.0.0.1', + isDataRequest: false, + isSubRequest: false, + }; +} + +function readStackEnvIfAny(): string { + const path = stackEnvFor(homeDir); + return existsSync(path) ? readFileSync(path, 'utf-8') : ''; +} + +const ENV_KEYS = [ + 'OP_UI_HOST_MODE', + 'OP_INSIDE_ELECTRON', + 'OP_ENABLE_ADMIN', + 'OP_HOME', + '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]; + } + homeDir = makeTempHome(); + process.env.OP_HOME = homeDir; + 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/host/stack — host stack settings (plan Phase 4 step 2)', () => { + test('200 in host-ui mode: project name + LAN exposure, and NO persona (partitioned)', 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; + expect(body.projectName).toBe('openpalm'); + expect(body.lanExposureEnabled).toBe(false); + // Persona is assistant-owned now — it must not leak into the host payload. + expect(body).not.toHaveProperty('personaContent'); + }); + + 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('403 in pwa-static mode even with a valid admin session', async () => { + process.env.OP_UI_HOST_MODE = 'pwa-static'; + const { GET } = await loadRoute(); + const res = await GET(makeGetEvent()); + expect(res.status).toBe(403); + }); + + test('401 in host-ui mode without a session cookie', async () => { + process.env.OP_UI_HOST_MODE = 'host-ui'; + const { GET } = await loadRoute(); + const res = await GET(makeGetEvent('')); + expect(res.status).toBe(401); + }); +}); + +describe('PUT /api/host/stack — host:stack:write guard (Phase 4 acceptance)', () => { + test('updates project name and bind address in host-ui mode — persona no longer required', async () => { + process.env.OP_UI_HOST_MODE = 'host-ui'; + const { PUT } = await loadRoute(); + const res = await PUT(makePutEvent({ projectName: 'openpalm-dev', lanExposureEnabled: true })); + expect(res.status).toBe(200); + + const stackEnv = readFileSync(stackEnvFor(homeDir), 'utf-8'); + expect(stackEnv).toContain('OP_PROJECT_NAME=openpalm-dev'); + expect(stackEnv).toContain('OP_ASSISTANT_BIND_ADDRESS=0.0.0.0'); + }); + + test('assistant-container cannot edit project name or bind address: 403 with a valid session', async () => { + process.env.OP_UI_HOST_MODE = 'assistant-container'; + const { PUT } = await loadRoute(); + const res = await PUT(makePutEvent({ projectName: 'intruder', lanExposureEnabled: true })); + expect(res.status).toBe(403); + // The write must have been refused before touching the stack env. + expect(readStackEnvIfAny()).not.toContain('intruder'); + }); + + test('401 in host-ui mode without a session cookie', async () => { + process.env.OP_UI_HOST_MODE = 'host-ui'; + const { PUT } = await loadRoute(); + const res = await PUT(makePutEvent({ projectName: 'openpalm', lanExposureEnabled: false }, '')); + expect(res.status).toBe(401); + }); +}); diff --git a/packages/ui/src/routes/admin/ui-version/+server.ts b/packages/ui/src/routes/api/host/ui-version/+server.ts similarity index 95% rename from packages/ui/src/routes/admin/ui-version/+server.ts rename to packages/ui/src/routes/api/host/ui-version/+server.ts index f7ee57fc8..748936ac3 100644 --- a/packages/ui/src/routes/admin/ui-version/+server.ts +++ b/packages/ui/src/routes/api/host/ui-version/+server.ts @@ -3,6 +3,7 @@ import { jsonResponse, errorResponse, requireAdmin, + requireCapability, } from "$lib/server/helpers.js"; import { seedUiBuild, resolveDataDir, createLogger, normalizeVersion } from "@openpalm/lib"; import type { RequestHandler } from "./$types"; @@ -11,6 +12,8 @@ const logger = createLogger("ui-version"); export const POST: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:updates', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/uninstall/+server.ts b/packages/ui/src/routes/api/host/uninstall/+server.ts similarity index 91% rename from packages/ui/src/routes/admin/uninstall/+server.ts rename to packages/ui/src/routes/api/host/uninstall/+server.ts index f93d04d5c..72d1c3fd1 100644 --- a/packages/ui/src/routes/admin/uninstall/+server.ts +++ b/packages/ui/src/routes/api/host/uninstall/+server.ts @@ -3,6 +3,7 @@ import { getRequestId, jsonResponse, requireAdmin, + requireCapability, } from "$lib/server/helpers.js"; import { getState } from "$lib/server/state.js"; import { withSerialQueue } from "$lib/server/serial-queue.js"; @@ -19,6 +20,8 @@ const logger = createLogger("uninstall"); export const POST: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:recovery', requestId); + if (capabilityError) return capabilityError; logger.info("uninstall request received", { requestId }); const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/uninstall/server.vitest.ts b/packages/ui/src/routes/api/host/uninstall/server.vitest.ts similarity index 89% rename from packages/ui/src/routes/admin/uninstall/server.vitest.ts rename to packages/ui/src/routes/api/host/uninstall/server.vitest.ts index 8bdd4b013..9251dfee2 100644 --- a/packages/ui/src/routes/admin/uninstall/server.vitest.ts +++ b/packages/ui/src/routes/api/host/uninstall/server.vitest.ts @@ -1,5 +1,5 @@ /** - * Thin route tests for POST /admin/uninstall (3.4 — mutating-endpoint coverage). + * Thin route tests for POST /api/host/uninstall (3.4 — mutating-endpoint coverage). * * Previously untested. Covers: auth gate, the docker-unavailable path (skips * compose down but still applies uninstall), the success path, and an @@ -27,7 +27,7 @@ import { POST } from './+server.js'; function makePostEvent(token = 'admin-token'): Parameters[0] { return { - request: new Request('http://localhost/admin/uninstall', { + request: new Request('http://localhost/api/host/uninstall', { method: 'POST', headers: { cookie: `op_session=${token}`, 'content-type': 'application/json' }, body: '{}', @@ -36,6 +36,9 @@ function makePostEvent(token = 'admin-token'): Parameters[0] { } beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; resetState('admin-token'); composeDownMock.mockReset(); checkDockerMock.mockReset(); @@ -47,10 +50,11 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; vi.clearAllMocks(); }); -describe('POST /admin/uninstall', () => { +describe('POST /api/host/uninstall', () => { test('requires admin auth', async () => { const res = await POST(makePostEvent('bad-token')); expect(res.status).toBe(401); diff --git a/packages/ui/src/routes/admin/unlock/+server.ts b/packages/ui/src/routes/api/host/unlock/+server.ts similarity index 91% rename from packages/ui/src/routes/admin/unlock/+server.ts rename to packages/ui/src/routes/api/host/unlock/+server.ts index 3cd09308f..1a7e56a44 100644 --- a/packages/ui/src/routes/admin/unlock/+server.ts +++ b/packages/ui/src/routes/api/host/unlock/+server.ts @@ -3,6 +3,7 @@ import { jsonResponse, errorResponse, requireAdmin, + requireCapability, } from "$lib/server/helpers.js"; import { getState } from "$lib/server/state.js"; import { @@ -22,6 +23,8 @@ const logger = createLogger("unlock-admin"); */ export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:recovery', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; @@ -47,6 +50,8 @@ export const GET: RequestHandler = async (event) => { */ export const POST: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:recovery', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/unlock/server.vitest.ts b/packages/ui/src/routes/api/host/unlock/server.vitest.ts similarity index 89% rename from packages/ui/src/routes/admin/unlock/server.vitest.ts rename to packages/ui/src/routes/api/host/unlock/server.vitest.ts index cd869fe99..01a0963c3 100644 --- a/packages/ui/src/routes/admin/unlock/server.vitest.ts +++ b/packages/ui/src/routes/api/host/unlock/server.vitest.ts @@ -1,5 +1,5 @@ /** - * Thin route tests for GET/POST /admin/unlock (3.4 — mutating-endpoint coverage). + * Thin route tests for GET/POST /api/host/unlock (3.4 — mutating-endpoint coverage). * * Previously untested. GET reports lock status (read-only); POST clears the * lock ONLY when stale, returning 409 install_in_progress for a live lock — @@ -25,7 +25,7 @@ import { GET, POST } from './+server.js'; function makeEvent(method: 'GET' | 'POST', token = 'admin-token'): Parameters[0] { return { - request: new Request('http://localhost/admin/unlock', { + request: new Request('http://localhost/api/host/unlock', { method, headers: { cookie: `op_session=${token}` }, }), @@ -51,6 +51,9 @@ const LIVE: InstallLockStatus = { }; beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; resetState('admin-token'); inspectInstallLockMock.mockReset(); unlockInstallLockMock.mockReset(); @@ -58,10 +61,11 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; vi.clearAllMocks(); }); -describe('GET /admin/unlock', () => { +describe('GET /api/host/unlock', () => { test('requires admin auth', async () => { const res = await GET(makeEvent('GET', 'bad-token')); expect(res.status).toBe(401); @@ -85,7 +89,7 @@ describe('GET /admin/unlock', () => { }); }); -describe('POST /admin/unlock', () => { +describe('POST /api/host/unlock', () => { test('requires admin auth', async () => { const res = await POST(makeEvent('POST', 'bad-token')); expect(res.status).toBe(401); diff --git a/packages/ui/src/routes/admin/update/+server.ts b/packages/ui/src/routes/api/host/update/+server.ts similarity index 98% rename from packages/ui/src/routes/admin/update/+server.ts rename to packages/ui/src/routes/api/host/update/+server.ts index 6a4212f39..4061aaa1b 100644 --- a/packages/ui/src/routes/admin/update/+server.ts +++ b/packages/ui/src/routes/api/host/update/+server.ts @@ -3,6 +3,7 @@ import { getRequestId, jsonResponse, requireAdmin, + requireCapability, } from "$lib/server/helpers.js"; import { getState } from "$lib/server/state.js"; import { withSerialQueue } from "$lib/server/serial-queue.js"; @@ -23,6 +24,8 @@ const logger = createLogger("update"); export const POST: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:updates', requestId); + if (capabilityError) return capabilityError; logger.info("update request received", { requestId }); const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/update/server.vitest.ts b/packages/ui/src/routes/api/host/update/server.vitest.ts similarity index 97% rename from packages/ui/src/routes/admin/update/server.vitest.ts rename to packages/ui/src/routes/api/host/update/server.vitest.ts index 540c7ff31..812c87a02 100644 --- a/packages/ui/src/routes/admin/update/server.vitest.ts +++ b/packages/ui/src/routes/api/host/update/server.vitest.ts @@ -1,5 +1,5 @@ /** - * Route-level tests for POST /admin/update. + * Route-level tests for POST /api/host/update. * * Phase 3: the route is now a thin wrapper over applyUpdate() (files) + * applyStack() (containers). Pull failure is FATAL (§6) — no "restarted @@ -50,7 +50,7 @@ function holdInstallLock(): void { function makePostEvent(token = 'admin-token', body: unknown = {}): Parameters[0] { return { - request: new Request('http://localhost/admin/update', { + request: new Request('http://localhost/api/host/update', { method: 'POST', headers: { cookie: `op_session=${token}`, @@ -63,6 +63,9 @@ function makePostEvent(token = 'admin-token', body: unknown = {}): Parameters { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; resetState('admin-token'); applyStackMock.mockReset(); checkDockerMock.mockReset(); @@ -76,13 +79,14 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; vi.clearAllMocks(); // The lock-contention tests write a foreign-held .install.lock into the // (test-shared) dataDir; remove it so it can't wedge later tests. rmSync(join(getState().dataDir, '.install.lock'), { force: true }); }); -describe('POST /admin/update', () => { +describe('POST /api/host/update', () => { test('requires admin auth', async () => { const res = await POST(makePostEvent('bad-token')); expect(res.status).toBe(401); diff --git a/packages/ui/src/routes/admin/versions/+server.ts b/packages/ui/src/routes/api/host/versions/+server.ts similarity index 94% rename from packages/ui/src/routes/admin/versions/+server.ts rename to packages/ui/src/routes/api/host/versions/+server.ts index fc6021801..abdd255aa 100644 --- a/packages/ui/src/routes/admin/versions/+server.ts +++ b/packages/ui/src/routes/api/host/versions/+server.ts @@ -1,6 +1,6 @@ /** - * GET /admin/versions — truthful version state (constitution §4.2, §5) - * PATCH /admin/versions — write pins to state file + * GET /api/host/versions — truthful version state (constitution §4.2, §5) + * PATCH /api/host/versions — write pins to state file * * GET returns THREE distinct values per component (§5 / Phase 5): * running — the digest + tag the currently running container was CREATED FROM @@ -22,7 +22,7 @@ * Background "available?" resolution degrades SILENTLY — a registry outage just * leaves `available` null; the stack keeps running what it has. * A user-pressed update that can't reach the registry FAILS LOUDLY (handled in - * /admin/update via applyStack pull-before-up + fatal pull failure). + * /api/host/update via applyStack pull-before-up + fatal pull failure). * * Voice variant on display (§4.2, compliance G3): * `running.tag` for the voice service will include the hardware suffix (e.g. @@ -45,7 +45,7 @@ import { json } from "@sveltejs/kit"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { getState } from "$lib/server/state.js"; -import { requireAdmin, getRequestId, errorResponse } from "$lib/server/helpers.js"; +import { requireAdmin, requireCapability, getRequestId, errorResponse } from "$lib/server/helpers.js"; import { readVersions, writeVersions, @@ -178,10 +178,12 @@ function findRunningByImage( return null; } -// ── GET /admin/versions ────────────────────────────────────────────────────── +// ── GET /api/host/versions ────────────────────────────────────────────────────── export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:updates', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; @@ -272,10 +274,12 @@ export const GET: RequestHandler = async (event) => { }); }; -// ── PATCH /admin/versions ──────────────────────────────────────────────────── +// ── PATCH /api/host/versions ──────────────────────────────────────────────────── export const PATCH: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:updates', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/versions/latest/+server.ts b/packages/ui/src/routes/api/host/versions/latest/+server.ts similarity index 95% rename from packages/ui/src/routes/admin/versions/latest/+server.ts rename to packages/ui/src/routes/api/host/versions/latest/+server.ts index 8a432b2ab..a157a3ed4 100644 --- a/packages/ui/src/routes/admin/versions/latest/+server.ts +++ b/packages/ui/src/routes/api/host/versions/latest/+server.ts @@ -1,5 +1,5 @@ import { json } from "@sveltejs/kit"; -import { requireAdmin, getRequestId } from "$lib/server/helpers.js"; +import { requireAdmin, requireCapability, getRequestId } from "$lib/server/helpers.js"; import { DOCKER_IMAGE_NAMES, SERVICE_VERSION_KEYS, compareComparableVersions } from "@openpalm/lib"; import type { RequestHandler } from "./$types"; @@ -68,6 +68,8 @@ let cacheExpiresAt = 0; export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:updates', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/versions/latest/server.vitest.ts b/packages/ui/src/routes/api/host/versions/latest/server.vitest.ts similarity index 86% rename from packages/ui/src/routes/admin/versions/latest/server.vitest.ts rename to packages/ui/src/routes/api/host/versions/latest/server.vitest.ts index a370ce5e3..e290e6929 100644 --- a/packages/ui/src/routes/admin/versions/latest/server.vitest.ts +++ b/packages/ui/src/routes/api/host/versions/latest/server.vitest.ts @@ -1,5 +1,5 @@ /** - * Route-level tests for GET /admin/versions/latest. + * Route-level tests for GET /api/host/versions/latest. * * Regression guard for the Docker Hub "latest tag" resolution: it MUST order by * last_updated (newest push first), not lexicographic -name. With -name, a tag @@ -13,8 +13,8 @@ import { GET } from './+server.js'; function makeGetEvent(token = 'admin-token'): Parameters[0] { return { - url: new URL('http://localhost/admin/versions/latest'), - request: new Request('http://localhost/admin/versions/latest', { + url: new URL('http://localhost/api/host/versions/latest'), + request: new Request('http://localhost/api/host/versions/latest', { method: 'GET', headers: { cookie: `op_session=${token}`, 'x-request-id': 'req-latest' }, }), @@ -37,14 +37,18 @@ const HUB_TAGS = { }; beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; resetState('admin-token'); }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; vi.unstubAllGlobals(); }); -describe('GET /admin/versions/latest', () => { +describe('GET /api/host/versions/latest', () => { test('rejects non-admin before any lookup', async () => { const res = await GET(makeGetEvent('bad-token')); expect(res.status).toBe(401); diff --git a/packages/ui/src/routes/admin/versions/releases/+server.ts b/packages/ui/src/routes/api/host/versions/releases/+server.ts similarity index 82% rename from packages/ui/src/routes/admin/versions/releases/+server.ts rename to packages/ui/src/routes/api/host/versions/releases/+server.ts index bd42288d6..7a2a5c346 100644 --- a/packages/ui/src/routes/admin/versions/releases/+server.ts +++ b/packages/ui/src/routes/api/host/versions/releases/+server.ts @@ -1,10 +1,10 @@ import { json } from "@sveltejs/kit"; -import { requireAdmin, getRequestId } from "$lib/server/helpers.js"; +import { requireAdmin, requireCapability, getRequestId } from "$lib/server/helpers.js"; import { selectInstallableReleases, type RawGitHubRelease } from "$lib/server/release-units.js"; import type { RequestHandler } from "./$types"; /** - * GET /admin/versions/releases — list installable platform releases from GitHub. + * GET /api/host/versions/releases — list installable platform releases from GitHub. * * Powers the desktop App-update badge: only platform releases that carry an * Electron installer asset. Best-effort — a GitHub outage yields an empty list @@ -13,6 +13,8 @@ import type { RequestHandler } from "./$types"; */ export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:updates', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/versions/releases/server.vitest.ts b/packages/ui/src/routes/api/host/versions/releases/server.vitest.ts similarity index 88% rename from packages/ui/src/routes/admin/versions/releases/server.vitest.ts rename to packages/ui/src/routes/api/host/versions/releases/server.vitest.ts index fd0bf35af..2d1fd7994 100644 --- a/packages/ui/src/routes/admin/versions/releases/server.vitest.ts +++ b/packages/ui/src/routes/api/host/versions/releases/server.vitest.ts @@ -1,9 +1,9 @@ /** - * Route-level tests for GET /admin/versions/releases. + * Route-level tests for GET /api/host/versions/releases. * * The releases endpoint now returns ONLY platform releases that carry Electron * installer assets (app-level releases). Container-image version pins live in - * stack.env and are edited via /admin/versions, not GitHub releases. Fetched + * stack.env and are edited via /api/host/versions, not GitHub releases. Fetched * fresh per request (no server-side cache). */ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; @@ -14,8 +14,8 @@ const originalFetch = globalThis.fetch; function makeGetEvent(token = 'admin-token'): Parameters[0] { return { - url: new URL('http://localhost/admin/versions/releases'), - request: new Request('http://localhost/admin/versions/releases', { + url: new URL('http://localhost/api/host/versions/releases'), + request: new Request('http://localhost/api/host/versions/releases', { method: 'GET', headers: { cookie: `op_session=${token}`, 'x-request-id': 'req-releases-test' }, }), @@ -30,15 +30,19 @@ function githubResponse(releases: Array<{ tag_name: string; prerelease: boolean; } beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; resetState('admin-token'); }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; vi.unstubAllGlobals(); globalThis.fetch = originalFetch; }); -describe('GET /admin/versions/releases', () => { +describe('GET /api/host/versions/releases', () => { test('requires admin auth', async () => { const res = await GET(makeGetEvent('bad-token')); expect(res.status).toBe(401); diff --git a/packages/ui/src/routes/admin/versions/server.vitest.ts b/packages/ui/src/routes/api/host/versions/server.vitest.ts similarity index 92% rename from packages/ui/src/routes/admin/versions/server.vitest.ts rename to packages/ui/src/routes/api/host/versions/server.vitest.ts index 6459c36f0..bede202f8 100644 --- a/packages/ui/src/routes/admin/versions/server.vitest.ts +++ b/packages/ui/src/routes/api/host/versions/server.vitest.ts @@ -1,5 +1,5 @@ /** - * Route-level tests for GET + PATCH /admin/versions. + * Route-level tests for GET + PATCH /api/host/versions. * * Pins are STATE (constitution §1): GET reads every version key (Docker image * tags) from OP_HOME/state, falling back to the legacy stack.env during the @@ -32,8 +32,8 @@ function seedStackEnv(content: string): void { function makeGetEvent(token = 'admin-token'): Parameters[0] { return { - url: new URL('http://localhost/admin/versions'), - request: new Request('http://localhost/admin/versions', { + url: new URL('http://localhost/api/host/versions'), + request: new Request('http://localhost/api/host/versions', { method: 'GET', headers: { cookie: `op_session=${token}`, 'x-request-id': 'req-versions-get' }, }), @@ -42,8 +42,8 @@ function makeGetEvent(token = 'admin-token'): Parameters[0] { function makePatchEvent(body: unknown, token = 'admin-token'): Parameters[0] { return { - url: new URL('http://localhost/admin/versions'), - request: new Request('http://localhost/admin/versions', { + url: new URL('http://localhost/api/host/versions'), + request: new Request('http://localhost/api/host/versions', { method: 'PATCH', headers: { cookie: `op_session=${token}`, 'content-type': 'application/json', 'x-request-id': 'req-versions-patch' }, body: JSON.stringify(body), @@ -54,14 +54,18 @@ function makePatchEvent(body: unknown, token = 'admin-token'): Parameters; platformVersion: string }; beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; resetState('admin-token'); }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; // resetState builds a fresh temp OP_HOME each run; nothing to undo. }); -describe('GET /admin/versions', () => { +describe('GET /api/host/versions', () => { test('requires admin auth', async () => { const res = await GET(makeGetEvent('bad-token')); expect(res.status).toBe(401); @@ -91,7 +95,7 @@ describe('GET /admin/versions', () => { }); }); -describe('PATCH /admin/versions', () => { +describe('PATCH /api/host/versions', () => { test('requires admin auth', async () => { const res = await PATCH(makePatchEvent({ versions: { OP_ASSISTANT_VERSION: 'v1' } }, 'bad-token')); expect(res.status).toBe(401); diff --git a/packages/ui/src/routes/admin/versions/ui/+server.ts b/packages/ui/src/routes/api/host/versions/ui/+server.ts similarity index 89% rename from packages/ui/src/routes/admin/versions/ui/+server.ts rename to packages/ui/src/routes/api/host/versions/ui/+server.ts index 20cbe2170..7456e1b9d 100644 --- a/packages/ui/src/routes/admin/versions/ui/+server.ts +++ b/packages/ui/src/routes/api/host/versions/ui/+server.ts @@ -1,5 +1,5 @@ import { json } from "@sveltejs/kit"; -import { requireAdmin, getRequestId } from "$lib/server/helpers.js"; +import { requireAdmin, requireCapability, getRequestId } from "$lib/server/helpers.js"; import type { RequestHandler } from "./$types"; /** One installable `@openpalm/ui` build from the npm registry. */ @@ -23,12 +23,14 @@ type Packument = { * * The UI is independently versioned and distributed via npm, so this is the * authoritative source of installable UI builds — the selected version is POSTed - * to /admin/ui-version, which seeds it from npm. Best-effort: a 404 (package not + * to /api/host/ui-version, which seeds it from npm. Best-effort: a 404 (package not * yet published) or registry outage yields an empty list. Newest-first. (No * server-side cache — the admin UI no longer polls this.) */ export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:updates', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/voice/+server.ts b/packages/ui/src/routes/api/host/voice/+server.ts similarity index 96% rename from packages/ui/src/routes/admin/voice/+server.ts rename to packages/ui/src/routes/api/host/voice/+server.ts index acb2e751b..3acf729a0 100644 --- a/packages/ui/src/routes/admin/voice/+server.ts +++ b/packages/ui/src/routes/api/host/voice/+server.ts @@ -1,8 +1,8 @@ /** - * GET /admin/voice — Return current TTS/STT env vars from stack.env plus + * GET /api/host/voice — Return current TTS/STT env vars from stack.env plus * an `availability` block (best-effort reachability of * the configured remote endpoints). - * PUT /admin/voice — Write TTS/STT env vars to stack.env. Auto-enables + * PUT /api/host/voice — Write TTS/STT env vars to stack.env. Auto-enables * the openpalm-voice addon, brings the chosen profile * up, waits for /health, and translates Docker errors * to operator-actionable copy. @@ -26,6 +26,7 @@ import { getRequestId, jsonResponse, requireAdmin, + requireCapability, } from '$lib/server/helpers.js'; import { withSerialQueue } from '$lib/server/serial-queue.js'; import { @@ -61,6 +62,8 @@ async function probeReachable(baseURL: string): Promise { export const GET: RequestHandler = async (event) => { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:stack:read', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; @@ -189,6 +192,8 @@ export const PUT: RequestHandler = (event) => { async function handlePut(event: Parameters[0]): Promise { const requestId = getRequestId(event); + const capabilityError = requireCapability(event, 'host:stack:write', requestId); + if (capabilityError) return capabilityError; const authError = requireAdmin(event, requestId); if (authError) return authError; diff --git a/packages/ui/src/routes/admin/voice/server.vitest.ts b/packages/ui/src/routes/api/host/voice/server.vitest.ts similarity index 96% rename from packages/ui/src/routes/admin/voice/server.vitest.ts rename to packages/ui/src/routes/api/host/voice/server.vitest.ts index 0334aa57d..ace390063 100644 --- a/packages/ui/src/routes/admin/voice/server.vitest.ts +++ b/packages/ui/src/routes/api/host/voice/server.vitest.ts @@ -5,7 +5,7 @@ import { randomBytes } from 'node:crypto'; import { tmpdir } from 'node:os'; // Stub the docker + addon-registry surface of @openpalm/lib so PUT -// /admin/voice's auto-enable + compose-up + healthcheck flow doesn't +// /api/host/voice's auto-enable + compose-up + healthcheck flow doesn't // reach for a real docker daemon. The save-path semantics (preset // auto-fill, validation, writeVoiceVars) live in the route itself // and are exercised below; whether docker actually starts the voice @@ -64,7 +64,7 @@ function makeTempDir(): string { function makeGetEvent(token = 'admin-token'): Parameters[0] { return { - request: new Request('http://localhost/admin/voice', { + request: new Request('http://localhost/api/host/voice', { headers: { cookie: `op_session=${token}`, 'x-request-id': 'req-voice-get', @@ -75,7 +75,7 @@ function makeGetEvent(token = 'admin-token'): Parameters[0] { function makePutEvent(body: Record, token = 'admin-token'): Parameters[0] { return { - request: new Request('http://localhost/admin/voice', { + request: new Request('http://localhost/api/host/voice', { method: 'PUT', headers: { cookie: `op_session=${token}`, @@ -92,6 +92,9 @@ let originalVoicePort: string | undefined; let fetchSpy: ReturnType | undefined; beforeEach(() => { + // Phase 4: /api/host + /api/assistant endpoints are capability-guarded; + // run this suite as a host-capable mode. + process.env.OP_UI_HOST_MODE = 'host-ui'; originalHome = process.env.OP_HOME; originalVoicePort = process.env.OP_VOICE_PORT_HOST; process.env.OP_HOME = makeTempDir(); @@ -106,6 +109,7 @@ beforeEach(() => { }); afterEach(() => { + delete process.env.OP_UI_HOST_MODE; process.env.OP_HOME = originalHome; if (originalVoicePort === undefined) delete process.env.OP_VOICE_PORT_HOST; else process.env.OP_VOICE_PORT_HOST = originalVoicePort; @@ -115,7 +119,7 @@ afterEach(() => { rmSync(getState().homeDir, { recursive: true, force: true }); }); -describe('PUT /admin/voice', () => { +describe('PUT /api/host/voice', () => { test('requires admin auth', async () => { const res = await PUT(makePutEvent({}, 'bad-token')); expect(res.status).toBe(401); @@ -203,7 +207,7 @@ describe('PUT /admin/voice', () => { }); }); -describe('GET /admin/voice', () => { +describe('GET /api/host/voice', () => { test('returns availability block', async () => { const res = await GET(makeGetEvent()); expect(res.status).toBe(200); @@ -239,7 +243,7 @@ describe('GET /admin/voice', () => { }); }); -describe('PUT /admin/voice — host fallback overlays', () => { +describe('PUT /api/host/voice — host fallback overlays', () => { test('skips rootless + cdi fallback when VITEST is set (deterministic test env)', async () => { // VITEST=1 is set by vitest; the route short-circuits the docker-info // probes so tests don't have to mock child_process. Confirm the 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)); +}; 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/attention/+page.svelte b/packages/ui/src/routes/attention/+page.svelte new file mode 100644 index 000000000..66f1a43f2 --- /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/chat/+page.svelte b/packages/ui/src/routes/chat/+page.svelte index b4c0ba60c..e6f902118 100644 --- a/packages/ui/src/routes/chat/+page.svelte +++ b/packages/ui/src/routes/chat/+page.svelte @@ -13,7 +13,10 @@ import QuestionCard from '$lib/components/chat/QuestionCard.svelte'; import { createFocusTrap, handleTrapKeydown } from '$lib/actions/focus-trap.js'; import { isLocalAssistantUrl } from '$lib/assistant-endpoint.js'; - import { probeChatBackend } from '$lib/api.js'; + // Direct domain-client import (plan Phase 3 step 4, #555): the chat page + // must not import the $lib/api.js barrel, which re-exports every admin + // domain client and would drag them all into the chat chunk. + import { probeChatBackend } from '$lib/api/chat.js'; import { advancedModeService } from '$lib/advanced-mode-state.svelte.js'; import { buildAdvancedPath } from '$lib/chat/navigation.js'; import { nextFollowState } from '$lib/chat/autoscroll.js'; @@ -30,15 +33,15 @@ stopConversation, initVoice } from '$lib/voice/voice-state.svelte.js'; - import IconSoundOn from '$lib/components/icons/IconSoundOn.svelte'; - import IconSoundOff from '$lib/components/icons/IconSoundOff.svelte'; - import IconConversations from '$lib/components/icons/IconConversations.svelte'; - import IconActivity from '$lib/components/icons/IconActivity.svelte'; - import IconClose from '$lib/components/icons/IconClose.svelte'; - import IconThemeSystem from '$lib/components/icons/IconThemeSystem.svelte'; - import IconThemeLight from '$lib/components/icons/IconThemeLight.svelte'; - import IconThemeDark from '$lib/components/icons/IconThemeDark.svelte'; - import IconAdvanced from '$lib/components/icons/IconAdvanced.svelte'; + import IconSoundOn from '@openpalm/ui-kit/components/icons/IconSoundOn.svelte'; + import IconSoundOff from '@openpalm/ui-kit/components/icons/IconSoundOff.svelte'; + import IconConversations from '@openpalm/ui-kit/components/icons/IconConversations.svelte'; + import IconActivity from '@openpalm/ui-kit/components/icons/IconActivity.svelte'; + import IconClose from '@openpalm/ui-kit/components/icons/IconClose.svelte'; + import IconThemeSystem from '@openpalm/ui-kit/components/icons/IconThemeSystem.svelte'; + import IconThemeLight from '@openpalm/ui-kit/components/icons/IconThemeLight.svelte'; + import IconThemeDark from '@openpalm/ui-kit/components/icons/IconThemeDark.svelte'; + import IconAdvanced from '@openpalm/ui-kit/components/icons/IconAdvanced.svelte'; let scrollAnchorEl = $state(); @@ -608,7 +611,7 @@
- + manage this assistant {/if} 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([]); + }); +}); diff --git a/packages/ui/src/routes/admin/endpoints/+page.svelte b/packages/ui/src/routes/connections/+page.svelte similarity index 73% rename from packages/ui/src/routes/admin/endpoints/+page.svelte rename to packages/ui/src/routes/connections/+page.svelte index 5a181b79e..38ab49827 100644 --- a/packages/ui/src/routes/admin/endpoints/+page.svelte +++ b/packages/ui/src/routes/connections/+page.svelte @@ -1,17 +1,20 @@ - Assistant Endpoints — OpenPalm + Connections — OpenPalm - +
- {#if endpointsService.error} - + {#if connectionsService.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} +
+ {#each connections as conn (conn.id)} +
+
+
+ {conn.label} + {#if conn.isDefault}Default{/if} + {#if conn.id === active?.id}Active{/if} + {#if conn.hasPassword}{/if}
-
{ep.url}
+
{conn.url}
-
- {#if ep.id !== active?.id} - {/if} - {#if !ep.isDefault} - {/if}
@@ -182,11 +188,11 @@ {#if formMode === 'idle'} {:else} -
-

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

+ +

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