Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/host-selfhost/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,11 @@
# "true". Enabling it lets users configure MCP servers whose commands execute
# on this host.
# EXECUTOR_ALLOW_STDIO_MCP=true

# --- Public OIDC client (PKCE) ------------------------------------------------
# Opt in explicitly. Keep verified email-domain admission; omit the client secret.
# EXECUTOR_SSO_PUBLIC_CLIENT=true
# EXECUTOR_SSO_PROVIDER_ID=example
# EXECUTOR_SSO_DISCOVERY_URL=https://idp.example.test/.well-known/openid-configuration
# EXECUTOR_SSO_CLIENT_ID=executor
# EXECUTOR_SSO_ALLOWED_DOMAINS=example.test
Binary file added apps/host-selfhost/docs/media/rivet-setup.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 27 additions & 0 deletions apps/host-selfhost/docs/public-oidc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Use a public OIDC client with PKCE

Selfhost SSO supports an explicit public-client mode. Set
`EXECUTOR_SSO_PUBLIC_CLIENT=true`, configure `EXECUTOR_SSO_CLIENT_ID`,
`EXECUTOR_SSO_PROVIDER_ID`, `EXECUTOR_SSO_DISCOVERY_URL`, and
`EXECUTOR_SSO_ALLOWED_DOMAINS`, and omit `EXECUTOR_SSO_CLIENT_SECRET`.
Register `/api/auth/oauth2/callback/PROVIDER_ID` at the configured canonical
browser origin as the IdP redirect URI. Authorization uses PKCE.

Public mode refuses a supplied client secret. Without the public-mode flag,
confidential SSO still requires the client ID and secret together. Provider names
and discovery presets retain their current behavior. Password login, first-run
setup, invite admission, and account-linking policy retain current main behavior.

The IdP must verify the user's email, and its domain must be allowlisted before a
new account is admitted. New admitted accounts receive membership under the existing
SSO rules. Public PKCE does not make an arbitrary issuer account an instance owner
or automatically link it to an unverified bootstrap identity.

Public PKCE preserves password login and existing account-linking policy. Configure
the current SSO variables under separate operator authority; source changes do not
alter deployed settings. The Okta emulator tests exercise public and confidential
discovery, authorization, callback, admission denial, and membership using synthetic
accounts. New public-client tests use a private per-run hosted instance created
through `/_emulate/instances` and assert authorize/token request-ledger evidence,
including PKCE and omission of the client secret. That test requires the synthetic
hosted control plane to be reachable; a local in-process IdP is not its substitute.
82 changes: 82 additions & 0 deletions apps/host-selfhost/docs/rivet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Connect a private Rivet service

Rivet owns the phone connection, Google session, archive, and write authorization.
Executor consumes its private MCP endpoint and proxies owner-only setup. The
server never hosts a Google browser or forwards browser keyboard or frame input.
Google sign-in opens the installed Rivet helper on a nearby computer.

Set `EXECUTOR_RIVET_SETUP_CONFIG_FILE` to an absolute owner-only JSON file with
`endpoint`, `tokenFile`, `ownerId`, `integration`, and `browserOrigin`. The endpoint
ends in `/setup` and must match the producer's `RIVET_SETUP_ENDPOINT`.
Management uses the MCP listener, normally 8779, independently of the phone's
8778 listener. Status includes both `setupEndpoint` and `phoneEndpoint`; the proxy
rejects a mismatched management endpoint before the browser can display a handoff.
Explicit HTTPS ports and private IPv6 literals are supported. It must use HTTPS
except for local fixtures. Its DNS addresses
must all be private. Requests pin one address and family, retain TLS hostname
validation, refuse redirects, and bound request and response sizes.

The token file contains a separate 64-character hexadecimal management credential.
It must belong to the service account and be private, regular, and not a symlink
or hard link. Keep it separate from the phone and ordinary MCP credentials. It
never reaches the browser. Only the configured existing `ownerId` may manage setup,
regardless of instance role. If `browserOrigin` is omitted, the server uses its
resolved canonical browser URL, including platform and localhost defaults.
Upstream authorization or missing-route failures keep an owner retry UI; only
local missing configuration or owner/session denial hides setup. Browser POSTs require the exact configured origin.
Unconfigured integrations hide the panel and return 404. Invalid configuration
fails closed. Equivalent `EXECUTOR_RIVET_SETUP_*` server variables are supported.

The panel reports phone connectivity, saved Google session, and Google authentication
separately. Verify saved sessions instead of pairing again. Hiding the phone QR
does not revoke its persistent credential. A later successful check supersedes
an old pairing failure. Authentication is point-in-time evidence.

For MCP health, select an explicit read-only operation such as `texts_gm_health`.
Discovery alone reports connection health and does not authenticate Google. The
plugin does not invoke candidates while listing them. Candidate operations must be compatible and read-only across every connection.
Pinned MCP arguments use JSON values, including numbers, booleans, arrays,
objects, and a literal argument named `body`. Call-time credential rejection is
expired; a scope shortfall requires broader grants rather than the same login.
Execution errors produce
bounded diagnostics without persisting arbitrary error content.

## Validate with synthetic fixtures

Run scoped Vitest tests in `apps/host-selfhost` for `rivet-setup*.test.ts` and
`rivet-setup.node.test.ts`, and in `packages/plugins/mcp` for `health-probe.test.ts`.
Run typechecks for the selfhost, MCP, and React packages, plus lint and the selfhost
production build.

The recorded browser scenario uses the native Rivet producer fixture. In a disposable
Rivet checkout, set `RIVET_SETUP_DEMO_DIR` to a new private directory under `/tmp`
and run `tsx scripts/setup-demo.ts`. Its `ready.json` provides the loopback endpoint.
Set `E2E_RIVET_SETUP_DIRECTORY`, `E2E_RIVET_SETUP_URL`, and
`EXECUTOR_RIVET_SETUP_CONFIG_FILE` to that fixture's directory, URL, and a new JSON
file inside the directory. Trust only the new fixture certificate by starting the
Executor process with `NODE_EXTRA_CA_CERTS` set to `ready.json`'s certificate path.
The fixture uses two separate HTTPS/WSS loopback listeners and rejects a reused
fixture directory. Then run from `e2e`:

```sh
../node_modules/.bin/vitest run --project selfhost selfhost/rivet-setup-ui.test.ts
```

The scenario creates a fresh Executor session and synthetic phone/account state.
It records QR display, phone connection, cancel/retry, single-use local handoff,
emoji confirmation, and verification. It does not launch a real Google login or
read a phone. Use a fresh fixture per run. The scenario removes its owner/config/phone-confirmation
sentinels in `Effect.ensuring`, including early failures. The controller must stop
and await its fixture process, then remove that exact disposable directory. Without the
native fixture, the cross-repository scenario reports its prerequisite as a skip.

## Source delivery and rollout

Review the Rivet producer and Executor consumer separately. Deploy the compatible
producer before enabling consumer management configuration, under separate operator
authority. Choose the exact owner and browser origin without changing account roles.
Rollback the consumer independently. Preserve the producer's current session,
archive, credentials, and deduplication receipts across runtime changes.

Source checks do not authorize deployment or prove phone pairing, real passkey
login, authenticated restart, natural refresh, or restore.
8 changes: 8 additions & 0 deletions apps/host-selfhost/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { selfHostDataMigrations } from "./db/data-migrations";
import { makeSelfHostAdminApiLayer } from "./admin/handlers";
import { makeSelfHostAdminUsersApiLayer } from "./admin/admin-users-api";
import { makeSelfHostSystemApiLayer } from "./system/handlers";
import { makeRivetSetupHandler, rivetSetupConfig } from "./rivet-setup";
import { selfHostAccountMiddleware } from "./account";
import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "./config";
import { createSelfHostDb, SelfHostDb, SelfHostDbProvider } from "./db/self-host-db";
Expand Down Expand Up @@ -126,6 +127,13 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => {
// reads paused detail (GET) and records the decision (POST .../resume),
// session-cookie-gated, delegating to the in-process MCP store.
HttpRouter.add("*", "/api/mcp-sessions/*", HttpEffect.fromWebHandler(mcp.approvalHandler)),
HttpRouter.add(
"*",
"/api/rivet-setup/*",
HttpEffect.fromWebHandler(
makeRivetSetupHandler(betterAuth, () => rivetSetupConfig(config.webBaseUrl)),
),
),
// App-local admin (invite-code) API, served under /api/admin/*.
makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),
// Tenant-wide admin users API (/api/admin/users*): the owner's view of
Expand Down
Loading
Loading