diff --git a/apps/host-selfhost/.env.example b/apps/host-selfhost/.env.example index 3e3296c884..644327e2eb 100644 --- a/apps/host-selfhost/.env.example +++ b/apps/host-selfhost/.env.example @@ -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 diff --git a/apps/host-selfhost/docs/media/rivet-setup.gif b/apps/host-selfhost/docs/media/rivet-setup.gif new file mode 100644 index 0000000000..5fdc55704c Binary files /dev/null and b/apps/host-selfhost/docs/media/rivet-setup.gif differ diff --git a/apps/host-selfhost/docs/public-oidc.md b/apps/host-selfhost/docs/public-oidc.md new file mode 100644 index 0000000000..e43080ef70 --- /dev/null +++ b/apps/host-selfhost/docs/public-oidc.md @@ -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. diff --git a/apps/host-selfhost/docs/rivet.md b/apps/host-selfhost/docs/rivet.md new file mode 100644 index 0000000000..613e962496 --- /dev/null +++ b/apps/host-selfhost/docs/rivet.md @@ -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. diff --git a/apps/host-selfhost/src/app.ts b/apps/host-selfhost/src/app.ts index 18bcdf9fc2..fee6a4cab5 100644 --- a/apps/host-selfhost/src/app.ts +++ b/apps/host-selfhost/src/app.ts @@ -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"; @@ -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 diff --git a/apps/host-selfhost/src/auth/sso-public.test.ts b/apps/host-selfhost/src/auth/sso-public.test.ts new file mode 100644 index 0000000000..c74faf6dbe --- /dev/null +++ b/apps/host-selfhost/src/auth/sso-public.test.ts @@ -0,0 +1,394 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomBytes } from "node:crypto"; + +import { afterAll, describe, expect, it, test } from "@effect/vitest"; +import { connectEmulator } from "@executor-js/emulate"; +import { Schema } from "effect"; + +import { mintInviteCode } from "../testing/mint-invite"; + +// Real Better Auth path with an SSO provider configured — and a REAL IdP on the +// wire: an @executor-js/emulate Okta instance serving OIDC discovery, +// authorize, and token, so the round-trip tests below exercise the same +// discovery -> redirect -> consent -> callback flow a production Google/Okta +// deployment does. Emulator + registered client + env must all exist before +// the app import, so `loadConfig` sees a fully-configured instance at boot. +const BASE = "http://localhost:4788"; +const CALLBACK = `${BASE}/api/auth/oauth2/callback/okta`; +const decodeRegistration = Schema.decodeUnknownSync(Schema.Struct({ client_id: Schema.String })); + +// Owner policy requires a private per-run hosted instance, not a released port reservation. +const created = await fetch("https://okta.emulators.dev/_emulate/instances", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ instance: "selfhost-public-oidc" }), + signal: AbortSignal.timeout(10_000), +}); +// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: a failed synthetic hosted control-plane request must fail fixture setup +if (!created.ok) throw new Error(`Synthetic Okta instance creation failed: HTTP ${created.status}`); +const instance = Schema.decodeUnknownSync(Schema.Struct({ providerBaseUrl: Schema.String }))( + await created.json(), +); +const client = await connectEmulator({ service: "okta", baseUrl: instance.providerBaseUrl }); +const idp = { url: instance.providerBaseUrl }; +afterAll(() => client.reset()); + +const dataDirectory = mkdtempSync(join(tmpdir(), "eh-sso-public-")); +const { loadConfig, emailDomain, handler } = await (async () => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: failed hosted fixture setup must reset its instance and remove local state before collection aborts + try { + const registrationResponse = await fetch(`${idp.url}/oauth2/v1/clients`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + client_name: "executor-selfhost-test", + redirect_uris: [CALLBACK], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }), + }); + expect(registrationResponse.status).toBe(201); + const registration = decodeRegistration(await registrationResponse.json()); + const managementCredential = await client.credentials.mint({ type: "api-key" }); + expect(managementCredential.token).toBeDefined(); + + const createIdpUser = async (email: string, firstName: string) => { + const response = await fetch(`${idp.url}/api/v1/users`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `SSWS ${managementCredential.token}`, + }, + body: JSON.stringify({ profile: { login: email, email, firstName, lastName: "User" } }), + }); + expect(response.status).toBe(201); + }; + await createIdpUser("alice@example.com", "Alice"); + await createIdpUser("mallory@evil.test", "Mallory"); + + process.env.EXECUTOR_DATA_DIR = dataDirectory; + process.env.BETTER_AUTH_SECRET = randomBytes(32).toString("hex"); + process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@test.local"; + process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-password-123"; + process.env.EXECUTOR_SSO_PROVIDER_ID = "okta"; + process.env.EXECUTOR_SSO_DISCOVERY_URL = `${idp.url}/.well-known/openid-configuration`; + process.env.EXECUTOR_SSO_CLIENT_ID = registration.client_id; + delete process.env.EXECUTOR_SSO_CLIENT_SECRET; + process.env.EXECUTOR_SSO_PUBLIC_CLIENT = "true"; + process.env.EXECUTOR_SSO_ALLOWED_DOMAINS = "Example.com, @second.example ,"; + + const { loadConfig } = await import("../config"); + const { emailDomain } = await import("./sso"); + const { makeSelfHostApiHandler } = await import("../app"); + + const { handler, dispose } = await makeSelfHostApiHandler(); + afterAll(async () => { + await dispose().finally(() => rmSync(dataDirectory, { recursive: true, force: true })); + }); + + return { loadConfig, emailDomain, handler }; + } catch (error) { + await client.reset().finally(() => rmSync(dataDirectory, { recursive: true, force: true })); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- preserve the actual fixture setup failure + throw error; + } +})(); + +const SSO_ENV_KEYS = [ + "EXECUTOR_SSO_PROVIDER_ID", + "EXECUTOR_SSO_PROVIDER_NAME", + "EXECUTOR_SSO_DISCOVERY_URL", + "EXECUTOR_SSO_CLIENT_ID", + "EXECUTOR_SSO_CLIENT_SECRET", + "EXECUTOR_SSO_ALLOWED_DOMAINS", + "EXECUTOR_SSO_PUBLIC_CLIENT", +] as const; + +// Run a block with the SSO env vars swapped out, restoring them afterwards so +// the booted instance's request-time config reads stay consistent. +const withSsoEnv = (overrides: Record, run: () => T): T => { + const saved = Object.fromEntries(SSO_ENV_KEYS.map((k) => [k, process.env[k]])); + for (const key of SSO_ENV_KEYS) { + const value = overrides[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: env save/restore around config reads must restore on assertion failure + try { + return run(); + } finally { + for (const key of SSO_ENV_KEYS) { + const value = saved[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +}; + +describe("public OIDC config resolution", () => { + it("requires explicit public mode and retains verified-domain admission", () => { + expect(loadConfig().sso?.publicClient).toBe(true); + expect(loadConfig().sso?.clientSecret).toBeUndefined(); + withSsoEnv( + { + EXECUTOR_SSO_PUBLIC_CLIENT: "true", + EXECUTOR_SSO_CLIENT_ID: "id", + EXECUTOR_SSO_CLIENT_SECRET: "secret", + }, + () => { + expect(() => loadConfig()).toThrow(/forbids/); + }, + ); + withSsoEnv({ EXECUTOR_SSO_PUBLIC_CLIENT: "true" }, () => { + expect(() => loadConfig()).toThrow(); + }); + withSsoEnv( + { + EXECUTOR_SSO_PUBLIC_CLIENT: "true", + EXECUTOR_SSO_CLIENT_ID: "id", + EXECUTOR_SSO_PROVIDER_ID: "google", + }, + () => { + expect(() => loadConfig()).toThrow(/ALLOWED_DOMAINS/); + }, + ); + }); + it("normalizes the domain allowlist (trim, lowercase, strip @, drop empties)", () => { + const sso = loadConfig().sso; + expect(sso).toBeDefined(); + expect(sso!.allowedDomains).toEqual(["example.com", "second.example"]); + }); + + it("derives the display name from the provider id when not set", () => { + expect(loadConfig().sso!.providerName).toBe("Okta"); + }); + + it("is undefined when no SSO env is set", () => { + withSsoEnv({}, () => { + expect(loadConfig().sso).toBeUndefined(); + }); + }); + + it("refuses half-configured credentials", () => { + withSsoEnv({ EXECUTOR_SSO_CLIENT_ID: "id-only" }, () => { + expect(() => loadConfig()).toThrow(/must be set together/); + }); + }); + + it("refuses credentials without a provider id", () => { + withSsoEnv({ EXECUTOR_SSO_CLIENT_ID: "id", EXECUTOR_SSO_CLIENT_SECRET: "secret" }, () => { + expect(() => loadConfig()).toThrow(/EXECUTOR_SSO_PROVIDER_ID/); + }); + }); + + it("presets Google's discovery document so only credentials + domains are needed", () => { + withSsoEnv( + { + EXECUTOR_SSO_PROVIDER_ID: "google", + EXECUTOR_SSO_CLIENT_ID: "id.apps.googleusercontent.com", + EXECUTOR_SSO_CLIENT_SECRET: "secret", + EXECUTOR_SSO_ALLOWED_DOMAINS: "example.com", + }, + () => { + const sso = loadConfig().sso; + expect(sso!.discoveryUrl).toBe( + "https://accounts.google.com/.well-known/openid-configuration", + ); + expect(sso!.providerName).toBe("Google"); + }, + ); + }); + + it("refuses a provider without a known or explicit discovery URL", () => { + withSsoEnv( + { + EXECUTOR_SSO_PROVIDER_ID: "okta", + EXECUTOR_SSO_CLIENT_ID: "id", + EXECUTOR_SSO_CLIENT_SECRET: "secret", + EXECUTOR_SSO_ALLOWED_DOMAINS: "example.com", + }, + () => { + expect(() => loadConfig()).toThrow(/EXECUTOR_SSO_DISCOVERY_URL/); + }, + ); + }); + + it("refuses a configured provider without a domain allowlist", () => { + withSsoEnv( + { + EXECUTOR_SSO_PROVIDER_ID: "okta", + EXECUTOR_SSO_DISCOVERY_URL: "https://idp.example/.well-known/openid-configuration", + EXECUTOR_SSO_CLIENT_ID: "id", + EXECUTOR_SSO_CLIENT_SECRET: "secret", + }, + () => { + expect(() => loadConfig()).toThrow(/EXECUTOR_SSO_ALLOWED_DOMAINS/); + }, + ); + }); +}); + +describe("emailDomain", () => { + it("extracts the lowercased domain of a well-formed address", () => { + expect(emailDomain("User@Example.COM")).toBe("example.com"); + }); + + it("returns null for malformed addresses instead of a matchable domain", () => { + expect(emailDomain("@example.com")).toBeNull(); + expect(emailDomain("user@")).toBeNull(); + expect(emailDomain("no-at-sign")).toBeNull(); + }); +}); + +test("auth-config advertises the configured provider (id + name only)", async () => { + const res = await handler(new Request(`${BASE}/api/auth-config`)); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ssoProviders: [{ id: "okta", name: "Okta" }] }); +}); + +// --- The full OIDC round-trip against the emulated IdP ----------------------- + +const decodeHtml = (value: string): string => + value + .replaceAll("&", "&") + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">"); + +const formFields = (form: string): Record => { + const fields: Record = {}; + for (const input of form.matchAll(/]*>/gi)) { + const tag = input[0]; + const name = tag.match(/\bname=["']([^"']+)["']/i)?.[1]; + const value = tag.match(/\bvalue=["']([^"']*)["']/i)?.[1] ?? ""; + if (name) fields[decodeHtml(name)] = decodeHtml(value); + } + return fields; +}; + +// Drive the whole flow as the given IdP account: sign-in redirect -> IdP +// consent page (pick the account's form) -> callback back into the app. +// Returns the callback response plus the cookies it set. +const signInThroughIdp = async (email: string) => { + await client.ledger.clear(); + const start = await handler( + new Request(`${BASE}/api/auth/sign-in/oauth2`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ providerId: "okta", callbackURL: "/" }), + }), + ); + expect(start.status).toBe(200); + const { url } = (await start.json()) as { url: string }; + expect(url.startsWith(idp.url)).toBe(true); + expect(new URL(url).searchParams.get("code_challenge_method")).toBe("S256"); + expect(new URL(url).searchParams.get("code_challenge")).toBeTruthy(); + const stateCookies = start.headers + .getSetCookie() + .map((cookie) => cookie.split(";")[0]!) + .join("; "); + + const consentHtml = await fetch(url).then((r) => r.text()); + const form = [...consentHtml.matchAll(//gi)] + .map((m) => m[0]) + .find((f) => f.includes(email)); + expect(form).toBeDefined(); + const action = decodeHtml(form!.match(/\baction=["']([^"']+)["']/i)![1]!); + const consent = await fetch(action, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(formFields(form!)), + redirect: "manual", + }); + expect(consent.status).toBe(302); + const callbackUrl = consent.headers.get("location")!; + expect(callbackUrl.startsWith(CALLBACK)).toBe(true); + + const callback = await handler(new Request(callbackUrl, { headers: { cookie: stateCookies } })); + const ledger = await client.ledger.list(); + expect( + ledger.some((entry) => /\/authorize$/.test(entry.path) && entry.response.status === 200), + ).toBe(true); + const token = ledger.find( + (entry) => /\/token$/.test(entry.path) && entry.response.status === 200, + ); + expect(token).toBeDefined(); + expect(JSON.stringify(token!.request.body)).toContain("code_verifier"); + expect(JSON.stringify(token!.request.body)).not.toContain("client_secret"); + const cookies = callback.headers + .getSetCookie() + .map((cookie) => cookie.split(";")[0]!) + .join("; "); + return { callback, cookies }; +}; + +test("an allowlisted-domain IdP account signs in and joins the org as a member", async () => { + const { callback, cookies } = await signInThroughIdp("alice@example.com"); + expect(callback.status).toBe(302); + expect(callback.headers.get("location")).toBe("/"); + + const session = await handler( + new Request(`${BASE}/api/auth/get-session`, { headers: { cookie: cookies } }), + ); + expect(session.status).toBe(200); + const sessionBody = (await session.json()) as { user?: { email?: string } }; + expect(sessionBody.user?.email).toBe("alice@example.com"); + + const orgs = await handler( + new Request(`${BASE}/api/auth/organization/list`, { headers: { cookie: cookies } }), + ); + expect(orgs.status).toBe(200); + expect(((await orgs.json()) as unknown[]).length).toBe(1); +}); + +test("an IdP account outside the allowlist is refused and gets no session", async () => { + const { callback, cookies } = await signInThroughIdp("mallory@evil.test"); + // The rejection surfaces as better-auth's error redirect, not a success. + expect(callback.status).toBe(302); + expect(callback.headers.get("location")).not.toBe("/"); + expect(callback.headers.get("location")).toContain("error"); + + const session = await handler( + new Request(`${BASE}/api/auth/get-session`, { headers: { cookie: cookies } }), + ); + const sessionBody = (await session.json()) as { user?: unknown } | null; + expect(sessionBody?.user ?? null).toBeNull(); +}); + +// --- Invite-code regressions with SSO configured ------------------------------ + +test("invite-gated email signup still works with an SSO provider configured", async () => { + const inviteCode = await mintInviteCode(handler); + const signUp = await handler( + new Request(`${BASE}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "member@test.local", + password: "member-password-123", + name: "Member", + inviteCode, + }), + }), + ); + expect(signUp.status).toBe(200); +}); + +test("email signup without an invite is still refused with an SSO provider configured", async () => { + const signUp = await handler( + new Request(`${BASE}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "stranger@example.com", + password: "stranger-password-123", + name: "Stranger", + }), + }), + ); + expect(signUp.status).toBe(403); +}); diff --git a/apps/host-selfhost/src/auth/sso.test.ts b/apps/host-selfhost/src/auth/sso.test.ts index 92f03d8791..2cf40df0ff 100644 --- a/apps/host-selfhost/src/auth/sso.test.ts +++ b/apps/host-selfhost/src/auth/sso.test.ts @@ -64,6 +64,7 @@ const SSO_ENV_KEYS = [ "EXECUTOR_SSO_CLIENT_ID", "EXECUTOR_SSO_CLIENT_SECRET", "EXECUTOR_SSO_ALLOWED_DOMAINS", + "EXECUTOR_SSO_PUBLIC_CLIENT", ] as const; // Run a block with the SSO env vars swapped out, restoring them afterwards so diff --git a/apps/host-selfhost/src/auth/sso.ts b/apps/host-selfhost/src/auth/sso.ts index 7d2d00a3df..7a69de286d 100644 --- a/apps/host-selfhost/src/auth/sso.ts +++ b/apps/host-selfhost/src/auth/sso.ts @@ -36,7 +36,7 @@ export const isAdmitted = ( export const ssoProviderConfig = (sso: SsoConfig) => ({ providerId: sso.providerId, clientId: sso.clientId, - clientSecret: sso.clientSecret, + ...(sso.publicClient ? {} : { clientSecret: sso.clientSecret }), discoveryUrl: sso.discoveryUrl, scopes: ["openid", "email", "profile"], pkce: true, diff --git a/apps/host-selfhost/src/config.ts b/apps/host-selfhost/src/config.ts index 07dcc56d14..3b011d71ea 100644 --- a/apps/host-selfhost/src/config.ts +++ b/apps/host-selfhost/src/config.ts @@ -25,7 +25,7 @@ export const SELF_HOST_SCHEMA_VERSION = "1.0.0"; * allowlist is what replaces the invite code for SSO sign-ups (the domain IS * the invite), so it is required whenever the provider is enabled. */ -export interface SsoConfig { +export type SsoConfig = { /** URL-safe id — also the OAuth callback path segment and the button key. */ readonly providerId: string; /** Display name for the login button (“Continue with ”). */ @@ -33,10 +33,12 @@ export interface SsoConfig { /** The IdP's OIDC discovery document (…/.well-known/openid-configuration). */ readonly discoveryUrl: string; readonly clientId: string; - readonly clientSecret: string; /** Lowercased email domains admitted without an invite code. */ readonly allowedDomains: readonly string[]; -} +} & ( + | { readonly publicClient: true; readonly clientSecret?: never } + | { readonly publicClient: false; readonly clientSecret: string } +); export interface SelfHostConfig { /** Bind address. Defaults to loopback. */ @@ -230,8 +232,13 @@ const PROVIDER_ID_PATTERN = /^[a-z0-9-]{1,48}$/; const resolveSso = (): SsoConfig | undefined => { const clientId = process.env.EXECUTOR_SSO_CLIENT_ID?.trim(); const clientSecret = process.env.EXECUTOR_SSO_CLIENT_SECRET?.trim(); - if (!clientId && !clientSecret) return undefined; - if (!clientId || !clientSecret) { + const publicClient = process.env.EXECUTOR_SSO_PUBLIC_CLIENT === "true"; + if (publicClient && clientSecret) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: public-client mode must not accidentally send a confidential credential + throw new Error("EXECUTOR_SSO_PUBLIC_CLIENT forbids EXECUTOR_SSO_CLIENT_SECRET"); + } + if (!clientId && !clientSecret && !publicClient) return undefined; + if (!clientId || (!publicClient && !clientSecret)) { // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on half-configured SSO credentials throw new Error("EXECUTOR_SSO_CLIENT_ID and EXECUTOR_SSO_CLIENT_SECRET must be set together"); } @@ -263,7 +270,10 @@ const resolveSso = (): SsoConfig | undefined => { const providerName = process.env.EXECUTOR_SSO_PROVIDER_NAME?.trim() || providerId.charAt(0).toUpperCase() + providerId.slice(1); - return { providerId, providerName, discoveryUrl, clientId, clientSecret, allowedDomains }; + const common = { providerId, providerName, discoveryUrl, clientId, allowedDomains }; + return clientSecret + ? { ...common, publicClient: false, clientSecret } + : { ...common, publicClient: true }; }; // A malformed value is refused rather than silently ignored: an operator who diff --git a/apps/host-selfhost/src/rivet-setup-config.test.ts b/apps/host-selfhost/src/rivet-setup-config.test.ts new file mode 100644 index 0000000000..5c53bdfa60 --- /dev/null +++ b/apps/host-selfhost/src/rivet-setup-config.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "@effect/vitest"; +import { rivetSetupConfig } from "./rivet-setup"; +describe("resolved Rivet browser origin", () => { + it("uses resolved config when no raw browser-origin environment variable exists", () => { + const keys = [ + "EXECUTOR_RIVET_SETUP_CONFIG_FILE", + "EXECUTOR_RIVET_SETUP_ENDPOINT", + "EXECUTOR_RIVET_SETUP_TOKEN_FILE", + "EXECUTOR_RIVET_SETUP_OWNER_ID", + "EXECUTOR_WEB_BASE_URL", + ] as const; + const previous = keys.map((key) => [key, process.env[key]] as const); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: restore process environment after the config assertion + try { + for (const key of keys) delete process.env[key]; + process.env.EXECUTOR_RIVET_SETUP_ENDPOINT = "https://setup.example.test:8779/setup"; + process.env.EXECUTOR_RIVET_SETUP_TOKEN_FILE = "/tmp/synthetic-rivet.token"; + process.env.EXECUTOR_RIVET_SETUP_OWNER_ID = "synthetic-owner"; + expect(rivetSetupConfig("https://canonical.example.test")).toMatchObject({ + browserOrigin: "https://canonical.example.test", + }); + expect(rivetSetupConfig("http://localhost:4788")).toMatchObject({ + browserOrigin: "http://localhost:4788", + }); + } finally { + for (const [key, value] of previous) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); +}); diff --git a/apps/host-selfhost/src/rivet-setup-network.test.ts b/apps/host-selfhost/src/rivet-setup-network.test.ts new file mode 100644 index 0000000000..443b9109c3 --- /dev/null +++ b/apps/host-selfhost/src/rivet-setup-network.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "@effect/vitest"; +import { isPrivateSetupAddress } from "./rivet-setup"; + +describe("private Rivet setup transport", () => { + it("accepts loopback, LAN and tailnet destinations, including mapped IPv6", () => { + for (const address of [ + "127.0.0.1", + "::1", + "10.0.1.2", + "192.168.1.2", + "172.16.0.1", + "100.64.0.1", + "fd00::1", + "::ffff:10.0.1.2", + ]) + expect(isPrivateSetupAddress(address)).toBe(true); + }); + it("refuses public, wildcard, link-local and unresolved destinations", () => { + for (const address of [ + "8.8.8.8", + "0.0.0.0", + "::", + "169.254.169.254", + "fe80::1", + "2001:4860::1", + "example.test", + "172.32.0.1", + "100.128.0.1", + ]) + expect(isPrivateSetupAddress(address)).toBe(false); + }); +}); diff --git a/apps/host-selfhost/src/rivet-setup-state.test.ts b/apps/host-selfhost/src/rivet-setup-state.test.ts new file mode 100644 index 0000000000..e1610e8db0 --- /dev/null +++ b/apps/host-selfhost/src/rivet-setup-state.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + decodeSetupStatus, + setupActive, + setupInstruction, + localSignInUrl, +} from "../web/rivet-setup-state"; + +const state = () => + decodeSetupStatus({ + phoneConnected: true, + phoneEndpoint: "wss://phone.example.test:8778/phone", + setupEndpoint: "https://setup.example.test:8779/setup", + googleConfigured: true, + googleSessionPresent: false, + googleAuthenticated: false, + checkedAt: null, + error: null, + google: null, + }); +describe("Rivet setup state", () => { + it("local Google sign-in stays bound to the separate setup listener and current one-time attempt", () => { + const uri = new URL("rivet-pair://google"); + const id = "00000000-0000-4000-8000-000000000001"; + uri.searchParams.set("endpoint", "https://setup.example.test:8779/setup/google/credentials"); + uri.searchParams.set("id", id); + uri.hash = "b".repeat(64); + const local = { + ...state(), + google: { + id, + state: "sign_in" as const, + method: "local-browser" as const, + expiresAt: Date.now() + 600000, + handoff: uri.href, + }, + }; + expect(localSignInUrl(local)).toBe(uri.href); + expect(localSignInUrl({ ...local, google: { ...local.google, expiresAt: 0 } })).toBeNull(); + expect(setupInstruction(local)).toContain("phone's passkey"); + uri.searchParams.set("endpoint", "https://another.example.test/setup/google/credentials"); + expect(localSignInUrl({ ...local, google: { ...local.google, handoff: uri.href } })).toBeNull(); + expect(localSignInUrl({ ...local, google: { ...local.google, state: "canceled" } })).toBeNull(); + }); + it("a connected phone does not imply Google authentication", () => { + expect(setupInstruction(state())).toContain("Connect Google Messages"); + expect(setupActive(state())).toBe(false); + }); + it("a saved session still requires upstream verification", () => { + expect(setupInstruction({ ...state(), googleSessionPresent: true })).toContain( + "Verify the saved", + ); + expect( + setupInstruction({ + ...state(), + googleSessionPresent: true, + googleAuthenticated: true, + checkedAt: 123, + }), + ).toContain("ready"); + }); + it("phone confirmation remains an explicit user action", () => { + const pending = { + ...state(), + google: { + id: "synthetic", + state: "phone_confirmation" as const, + expiresAt: 123, + emoji: "🦊", + }, + }; + expect(setupActive(pending)).toBe(true); + expect(setupInstruction(pending)).toContain("confirm the matching emoji"); + }); + it("a saved session survives failed initial verification and a later successful check", () => { + const failed = { + ...state(), + google: { + id: "synthetic", + state: "failed" as const, + expiresAt: 123, + error: "GOOGLE_PAIRING_FAILED", + }, + }; + expect(setupInstruction(failed)).toContain("Retry"); + expect(setupInstruction({ ...failed, googleSessionPresent: true })).toContain( + "Verify the saved", + ); + expect( + setupInstruction({ + ...failed, + googleSessionPresent: true, + googleAuthenticated: true, + checkedAt: 123, + }), + ).toContain("ready"); + expect(setupActive(failed)).toBe(false); + }); + it("refuses malformed states instead of marking a connection ready", () => { + expect(() => decodeSetupStatus({ ...state(), phoneConnected: "true" })).toThrow(); + expect(() => + decodeSetupStatus({ + ...state(), + google: { id: "synthetic", state: "anything", expiresAt: 123 }, + }), + ).toThrow(); + }); +}); diff --git a/apps/host-selfhost/src/rivet-setup.node.test.ts b/apps/host-selfhost/src/rivet-setup.node.test.ts new file mode 100644 index 0000000000..88ab979ff9 --- /dev/null +++ b/apps/host-selfhost/src/rivet-setup.node.test.ts @@ -0,0 +1,236 @@ +import { createServer } from "node:https"; +import { getCACertificates, setDefaultCACertificates } from "node:tls"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { readFileSync } from "node:fs"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, expect, test } from "@effect/vitest"; + +const directory = mkdtempSync(join(tmpdir(), "executor-rivet-owner-")); +process.env.EXECUTOR_DATA_DIR = directory; +process.env.BETTER_AUTH_SECRET = "synthetic-rivet-setup-secret-0123456789abcdef"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@rivet-setup.test"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "synthetic-admin-password-123"; +process.env.EXECUTOR_RIVET_SETUP_CONFIG_FILE = join(directory, "setup-config.json"); +const config = ( + ownerId: string, + endpoint = "http://127.0.0.1:1/setup", + tokenFile = join(directory, "not-created.token"), +) => + writeFileSync( + process.env.EXECUTOR_RIVET_SETUP_CONFIG_FILE!, + JSON.stringify({ + endpoint, + tokenFile, + ownerId, + integration: "synthetic-rivet", + browserOrigin: "http://localhost:4788", + }), + { mode: 0o600 }, + ); +config("not-the-owner"); +const { makeSelfHostApiHandler } = await import("./app"); +let app = await makeSelfHostApiHandler(); +afterAll(async () => { + await app.dispose(); + rmSync(directory, { recursive: true, force: true }); +}); + +test("Rivet management uses the real session gate, exact configured account owner, and same-origin requests", async () => { + const url = "http://localhost:4788/api/rivet-setup/synthetic-rivet/"; + expect((await app.handler(new Request(url + "status"))).status).toBe(401); + const signedIn = await app.handler( + new Request("http://localhost:4788/api/auth/sign-in/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL, + password: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD, + }), + }), + ); + expect(signedIn.status).toBe(200); + const session = (await signedIn.json()) as { user: { id: string } }; + const token = signedIn.headers.get("set-auth-token")!; + expect(token).toBeTruthy(); + const auth = { Authorization: `Bearer ${token}` }; + expect((await app.handler(new Request(url + "status", { headers: auth }))).status).toBe(403); + config(session.user.id); + const post = (origin: string | null) => + app.handler( + new Request(url + "phone-qr", { + method: "POST", + headers: { ...auth, "content-type": "application/json", ...(origin ? { origin } : {}) }, + body: "{}", + }), + ); + expect((await post(null)).status).toBe(403); + expect((await post("https://another.example.test")).status).toBe(403); + expect((await post("http://localhost:4788")).status).toBe(502); // Authorized; missing owner token refuses upstream access. + expect( + ( + await app.handler( + new Request(url.replace("synthetic-rivet", "another-integration") + "status", { + headers: auth, + }), + ) + ).status, + ).toBe(404); +}); + +test("The configured SSO account can own setup without an instance admin role", async () => { + // Close the app before opening a fixture writer; never create competing WAL + // connections to the running self-host database. + await app.dispose(); + const { createClient } = await import("@libsql/client"); + const db = createClient({ url: `file:${join(directory, "data.db")}` }); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- fixture boundary: close the offline database writer even when setup fails + try { + await db.execute("UPDATE member SET role = 'member'"); + } finally { + db.close(); + } + app = await makeSelfHostApiHandler(); + const signedIn = await app.handler( + new Request("http://localhost:4788/api/auth/sign-in/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL, + password: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD, + }), + }), + ); + expect(signedIn.status).toBe(200); + const session = (await signedIn.json()) as { user: { id: string } }; + config(session.user.id); + const headers = { Authorization: `Bearer ${signedIn.headers.get("set-auth-token")!}` }; + expect( + ( + await app.handler( + new Request("http://localhost:4788/api/rivet-setup/synthetic-rivet/status", { headers }), + ) + ).status, + ).toBe(502); + config("another-owner"); + expect( + ( + await app.handler( + new Request("http://localhost:4788/api/rivet-setup/synthetic-rivet/status", { headers }), + ) + ).status, + ).toBe(403); +}); + +test("IPv6 setup transport preserves TLS, pins management identity, and keeps upstream failures retryable", async () => { + const run = promisify(execFile); + const certPath = join(directory, "fixture.crt"), + keyPath = join(directory, "fixture.key"); + await run("openssl", [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-keyout", + keyPath, + "-out", + certPath, + "-days", + "1", + "-subj", + "/CN=localhost", + "-addext", + "subjectAltName=IP:::1", + ]); + const originalTrust = getCACertificates("default"); + setDefaultCACertificates([...originalTrust, readFileSync(certPath, "utf8")]); + let upstreamStatus = 200, + requests = 0; + let setupEndpoint = ""; + const upstream = createServer( + { cert: readFileSync(certPath), key: readFileSync(keyPath) }, + (req, res) => { + requests++; + expect(req.headers.authorization).toBe("Bearer " + "b".repeat(64)); + res.writeHead(upstreamStatus, { + "content-type": "application/json", + ...(upstreamStatus === 302 ? { location: "/setup/status" } : {}), + }); + res.end( + JSON.stringify({ + phoneConnected: false, + phoneEndpoint: "wss://phone.example.test:8778/phone", + setupEndpoint, + googleConfigured: true, + googleSessionPresent: false, + googleAuthenticated: false, + checkedAt: null, + error: null, + google: null, + }), + ); + }, + ); + await new Promise((resolve) => upstream.listen(0, "::1", resolve)); + const address = upstream.address(); + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: the native TLS fixture requires an allocated port + if (!address || typeof address === "string") throw new Error("Fixture port required"); + const endpoint = `https://[::1]:${address.port}/setup`; + setupEndpoint = endpoint; + const tokenFile = join(directory, "fixture.token"); + writeFileSync(tokenFile, "b".repeat(64), { mode: 0o600 }); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: always close the native TLS server and restore per-worker CA trust + try { + const signedIn = await app.handler( + new Request("http://localhost:4788/api/auth/sign-in/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL, + password: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD, + }), + }), + ); + const session = (await signedIn.json()) as { user: { id: string } }; + config(session.user.id, endpoint, tokenFile); + const url = "http://localhost:4788/api/rivet-setup/synthetic-rivet/status"; + const get = () => + app.handler( + new Request(url, { + headers: { authorization: `Bearer ${signedIn.headers.get("set-auth-token")!}` }, + }), + ); + expect((await get()).status).toBe(200); + for (const status of [401, 403, 404]) { + upstreamStatus = status; + const result = await get(); + expect(result.status).toBe(502); + expect(await result.json()).toEqual({ + error: + status === 404 + ? "SETUP_UPSTREAM_ROUTE_UNAVAILABLE" + : "SETUP_UPSTREAM_AUTHORIZATION_FAILED", + }); + } + upstreamStatus = 200; + setupEndpoint = "https://[::1]:1/setup"; + const mismatch = await get(); + expect(mismatch.status).toBe(502); + expect(await mismatch.json()).toEqual({ error: "SETUP_ENDPOINT_MISMATCH" }); + setupEndpoint = endpoint; + expect((await get()).status).toBe(200); + upstreamStatus = 302; + const before = requests; + expect((await get()).status).toBe(502); + expect(requests).toBe(before + 1); + } finally { + await new Promise((resolve, reject) => + // oxlint-disable-next-line executor/no-promise-reject -- boundary: adapt Node server shutdown errors to the test lifecycle + upstream.close((error) => (error ? reject(error) : resolve())), + ); + setDefaultCACertificates(originalTrust); + } +}); diff --git a/apps/host-selfhost/src/rivet-setup.ts b/apps/host-selfhost/src/rivet-setup.ts new file mode 100644 index 0000000000..324af4a9de --- /dev/null +++ b/apps/host-selfhost/src/rivet-setup.ts @@ -0,0 +1,276 @@ +/* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor, executor/no-promise-reject, executor/no-promise-catch -- Owner management is a Node HTTP and credential-file boundary. Failures return fixed machine codes. */ +import { constants, openSync, fstatSync, readFileSync, closeSync } from "node:fs"; +import { isAbsolute } from "node:path"; +import { lookup } from "node:dns/promises"; +import { BlockList, isIP } from "node:net"; +import { request as httpRequest } from "node:http"; +import { request as httpsRequest } from "node:https"; +import { Schema } from "effect"; + +import { SetupStatusSchema } from "../web/rivet-setup-state"; + +import type { BetterAuthHandle } from "./auth/better-auth"; + +const decodeStatus = Schema.decodeUnknownSync(SetupStatusSchema); +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +export interface RivetSetupConfig { + readonly endpoint: string; + readonly tokenFile: string; + readonly ownerId: string; + readonly integration: string; + readonly browserOrigin: string; +} + +export function rivetSetupConfig(resolvedBrowserOrigin: string): RivetSetupConfig | null { + let configured: Partial = {}; + const path = process.env.EXECUTOR_RIVET_SETUP_CONFIG_FILE; + if (path) { + if (!isAbsolute(path)) throw new Error("Invalid Rivet setup configuration path"); + const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const stat = fstatSync(fd); + if ( + !stat.isFile() || + stat.uid !== process.getuid?.() || + stat.mode & 0o077 || + stat.size > 16_384 + ) + throw new Error("Invalid Rivet setup configuration file"); + const value: unknown = decodeJson(readFileSync(fd, "utf8")); + if ( + value === null || + typeof value !== "object" || + Array.isArray(value) || + Object.entries(value).some( + ([key, field]) => + !["endpoint", "tokenFile", "ownerId", "integration", "browserOrigin"].includes(key) || + typeof field !== "string", + ) + ) + throw new Error("Invalid Rivet setup configuration fields"); + configured = value; + } finally { + closeSync(fd); + } + } + const endpoint = configured.endpoint ?? process.env.EXECUTOR_RIVET_SETUP_ENDPOINT; + const tokenFile = configured.tokenFile ?? process.env.EXECUTOR_RIVET_SETUP_TOKEN_FILE; + const ownerId = configured.ownerId ?? process.env.EXECUTOR_RIVET_SETUP_OWNER_ID; + const origin = configured.browserOrigin ?? resolvedBrowserOrigin; + if (!endpoint || !tokenFile || !ownerId || !origin) return null; + const url = new URL(endpoint); + if ( + url.username || + url.password || + url.search || + url.hash || + url.pathname !== "/setup" || + !( + url.protocol === "https:" || + (url.protocol === "http:" && ["127.0.0.1", "localhost"].includes(url.hostname)) + ) || + !isAbsolute(tokenFile) || + !/^[a-zA-Z0-9_-]{1,128}$/.test(ownerId) + ) + throw new Error("Invalid Rivet setup configuration"); + return { + endpoint, + tokenFile, + ownerId, + integration: configured.integration ?? process.env.EXECUTOR_RIVET_SETUP_INTEGRATION ?? "rivet", + browserOrigin: new URL(origin).origin, + }; +} + +function setupToken(path: string): string { + const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const stat = fstatSync(fd); + if ( + !stat.isFile() || + stat.nlink !== 1 || + stat.uid !== process.getuid?.() || + stat.mode & 0o077 || + stat.size > 128 + ) + throw new Error("Invalid setup credential file"); + const token = readFileSync(fd, "utf8").trim(); + if (!/^[a-f0-9]{64}$/.test(token)) throw new Error("Invalid setup credential"); + return token; + } finally { + closeSync(fd); + } +} + +const privateNetwork = new BlockList(); +privateNetwork.addSubnet("127.0.0.0", 8, "ipv4"); +privateNetwork.addAddress("::1", "ipv6"); +privateNetwork.addSubnet("10.0.0.0", 8, "ipv4"); +privateNetwork.addSubnet("172.16.0.0", 12, "ipv4"); +privateNetwork.addSubnet("192.168.0.0", 16, "ipv4"); +privateNetwork.addSubnet("100.64.0.0", 10, "ipv4"); +privateNetwork.addSubnet("fc00::", 7, "ipv6"); + +export const isPrivateSetupAddress = (address: string): boolean => { + const family = isIP(address); + return family !== 0 && privateNetwork.check(address, family === 6 ? "ipv6" : "ipv4"); +}; + +async function setupRequest( + url: URL, + method: string, + headers: Record, + body?: string, +) { + const hostname = url.hostname.replace(/^\[|\]$/g, ""); + const family = isIP(hostname); + const addresses = family + ? [{ address: hostname, family }] + : await lookup(hostname, { all: true }); + if (addresses.length === 0 || addresses.some((entry) => !isPrivateSetupAddress(entry.address))) + throw new Error("Private setup destination required"); + const address = addresses.find((entry) => entry.family === 4) ?? addresses[0]!; + return new Promise<{ status: number; value: unknown }>((resolve, reject) => { + const request = (url.protocol === "https:" ? httpsRequest : httpRequest)( + url, + { + method, + headers, + signal: AbortSignal.timeout(40_000), + family: address.family, + hostname, + ...(url.protocol === "https:" ? { servername: isIP(hostname) ? "" : hostname } : {}), + lookup: (_hostname, _options, callback) => callback(null, address.address, address.family), + }, + (upstream) => { + // No redirects, including redirects to another private host. TLS still + // validates the configured hostname while DNS stays pinned for this request. + if (!upstream.statusCode || (upstream.statusCode >= 300 && upstream.statusCode < 400)) { + upstream.destroy(); + reject(new Error("Invalid setup response")); + return; + } + const chunks: Buffer[] = []; + let size = 0; + upstream.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > 2 * 1024 * 1024) { + upstream.destroy(new Error("Setup response too large")); + return; + } + chunks.push(chunk); + }); + upstream.on("error", reject); + upstream.on("end", () => { + try { + const raw = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)); + resolve({ status: upstream.statusCode ?? 502, value: decodeJson(raw) }); + } catch { + reject(new Error("Invalid setup response")); + } + }); + }, + ); + request.on("error", reject); + request.end(body); + }); +} + +const routes = new Map([ + ["status", "GET"], + ["phone-qr", "POST"], + ["verify", "POST"], + ["google/start", "POST"], + ["google/cancel", "POST"], +]); +const response = (status: number, body: unknown) => + Response.json(body, { + status, + headers: { + "Cache-Control": "no-store", + Pragma: "no-cache", + "X-Content-Type-Options": "nosniff", + }, + }); + +/** Separate owner management bridge; normal MCP credentials never enter this path. */ +export function makeRivetSetupHandler( + betterAuth: BetterAuthHandle, + config: () => RivetSetupConfig | null, +) { + return async (request: Request): Promise => { + let settings: RivetSetupConfig | null; + try { + settings = config(); + } catch { + return response(503, { error: "SETUP_CONFIGURATION_INVALID" }); + } + if (!settings) return response(404, { error: "SETUP_NOT_CONFIGURED" }); + const path = new URL(request.url).pathname.match(/^\/api\/rivet-setup\/([^/]+)\/(.+)$/); + if (!path || path[1] !== settings.integration || routes.get(path[2]!) !== request.method) + return response(404, { error: "SETUP_ROUTE_NOT_FOUND" }); + // Setup belongs to the explicitly configured account, independent of its + // instance role. A local admin record must not stand in for an SSO user. + const session = await betterAuth.auth.api + .getSession({ headers: request.headers }) + .catch(() => null); + if (!session) return response(401, { error: "SETUP_OWNER_REQUIRED" }); + if (session.user.id !== settings.ownerId) + return response(403, { error: "SETUP_OWNER_REQUIRED" }); + let body: string | undefined; + if (request.method === "POST") { + if ( + request.headers.get("origin") !== settings.browserOrigin || + request.headers.get("content-type") !== "application/json" + ) + return response(403, { error: "SETUP_SAME_ORIGIN_REQUIRED" }); + const reader = request.body?.getReader(); + if (!reader) return response(400, { error: "SETUP_BODY_REQUIRED" }); + let bytes = new Uint8Array(); + try { + for (;;) { + const chunk = await reader.read(); + if (chunk.done) break; + if (bytes.length + chunk.value.length > 16_384) { + await reader.cancel(); + return response(413, { error: "SETUP_BODY_TOO_LARGE" }); + } + const combined = new Uint8Array(bytes.length + chunk.value.length); + combined.set(bytes); + combined.set(chunk.value, bytes.length); + bytes = combined; + } + body = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + decodeJson(body); + } catch { + return response(400, { error: "INVALID_SETUP_BODY" }); + } + } + try { + const upstream = await setupRequest( + new URL(`${settings.endpoint}/${path[2]}`), + request.method, + { + Authorization: `Bearer ${setupToken(settings.tokenFile)}`, + "X-Rivet-Setup-Owner": session.user.id, + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body, + ); + if ([401, 403].includes(upstream.status)) + return response(502, { error: "SETUP_UPSTREAM_AUTHORIZATION_FAILED" }); + if (upstream.status === 404) + return response(502, { error: "SETUP_UPSTREAM_ROUTE_UNAVAILABLE" }); + if (request.method === "GET" && path[2] === "status" && upstream.status === 200) { + const status = decodeStatus(upstream.value); + if (new URL(status.setupEndpoint).href !== new URL(settings.endpoint).href) + return response(502, { error: "SETUP_ENDPOINT_MISMATCH" }); + return response(200, status); + } + return response(upstream.status, upstream.value); + } catch { + return response(502, { error: "SETUP_SERVICE_UNAVAILABLE" }); + } + }; +} diff --git a/apps/host-selfhost/web/rivet-setup-panel.tsx b/apps/host-selfhost/web/rivet-setup-panel.tsx new file mode 100644 index 0000000000..4d72d17719 --- /dev/null +++ b/apps/host-selfhost/web/rivet-setup-panel.tsx @@ -0,0 +1,296 @@ +/* oxlint-disable executor/no-try-catch-or-throw, executor/no-error-constructor -- This component is the browser fetch and keyboard adapter; imperative failures become transient React UI state, outside the Effect domain. */ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Schema } from "effect"; +import { Button } from "@executor-js/react/components/button"; +import { + decodeSetupStatus, + setupActive, + setupInstruction, + localSignInUrl, + type SetupStatus, +} from "./rivet-setup-state"; + +const Image = Schema.Struct({ + image: Schema.String, + endpoint: Schema.optional(Schema.String), +}); +const decodeImage = Schema.decodeUnknownSync(Image); + +export function RivetSetupPanel(props: { readonly integration: string }) { + const [status, setStatus] = useState(null); + const [available, setAvailable] = useState(true); + const [qr, setQr] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [waitingUntil, setWaitingUntil] = useState(0); + const base = `/api/rivet-setup/${encodeURIComponent(props.integration)}`; + const mounted = useRef(true); + const actionPending = useRef(false); + + const call = useCallback( + async (route: string, body?: unknown) => { + const result = await fetch(`${base}/${route}`, { + credentials: "same-origin", + cache: "no-store", + ...(body === undefined + ? {} + : { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + signal: AbortSignal.timeout(45_000), + }); + if (result.status === 404 || result.status === 403 || result.status === 401) { + if (mounted.current) { + setAvailable(false); + setStatus(null); + setQr(null); + } + throw new Error("SETUP_UNAVAILABLE"); + } + const json: unknown = await result.json(); + if (!result.ok) throw new Error("SETUP_OPERATION_FAILED"); + return json; + }, + [base], + ); + + const refresh = useCallback(async () => { + const current = decodeSetupStatus(await call("status")); + if (mounted.current) { + setStatus(current); + if (current.phoneConnected) setQr(null); + } + return current; + }, [call]); + + useEffect(() => { + mounted.current = true; + setAvailable(true); + setStatus(null); + setQr(null); + setError(null); + void (async () => { + try { + await refresh(); + } catch { + if (mounted.current) setError("Could not check Rivet setup. Retry the connection check."); + } + })(); + return () => { + mounted.current = false; + }; + }, [refresh]); + + const polling = qr !== null || (status !== null && setupActive(status)); + useEffect(() => { + if (!polling) return; + let stopped = false; + let pending = false; + const deadline = waitingUntil || status?.google?.expiresAt || Date.now() + 600_000; + const tick = async () => { + if (stopped || pending || document.hidden) return; + if (Date.now() >= deadline) { + stopped = true; + setQr(null); + setError("Setup wait expired. Check the phone or start setup again."); + return; + } + pending = true; + try { + await refresh(); + if (!stopped && mounted.current) setError(null); + } catch { + if (!stopped && mounted.current) + setError("Could not refresh setup. Your saved connections are preserved."); + } finally { + pending = false; + } + }; + const timer = setInterval(() => { + void tick(); + }, 1500); + void tick(); + return () => { + stopped = true; + clearInterval(timer); + }; + }, [polling, call, refresh, waitingUntil, status?.google?.expiresAt]); + + const act = async (operation: () => Promise) => { + if (actionPending.current) return; + actionPending.current = true; + setBusy(true); + setError(null); + try { + await operation(); + } catch { + if (mounted.current) setError("Setup could not finish. Check the connection and retry."); + } finally { + actionPending.current = false; + if (mounted.current) setBusy(false); + } + }; + + if (!available) return null; + if (!status) + return error ? ( +
+

{error}

+ +
+ ) : null; + const active = setupActive(status); + const handoff = localSignInUrl(status); + + return ( +
+

Set up Rivet

+
+ + Phone: {status.phoneConnected ? "Connected" : "Needs connection"} + + + Google Messages:{" "} + + {status.googleAuthenticated + ? "Authenticated" + : status.googleConfigured + ? "Needs pairing or verification" + : "Not configured"} + + +
+

{setupInstruction(status)}

+ {handoff ? ( +
+ + Sign in with Google passkey + +

+ Opens the Rivet helper in a fresh local Chrome session. Keep your phone nearby with + Bluetooth enabled. This one-time link expires in ten minutes. +

+
+ ) : null} +
+ {!status.phoneConnected && !qr ? ( + + ) : null} + {status.googleConfigured && !status.googleSessionPresent && !active ? ( + + ) : null} + {!active ? ( + + ) : null} + {active ? ( + + ) : null} + {qr ? ( + + ) : null} +
+ {qr ? ( +
+

{status.phoneEndpoint}

+ Scan this private QR in Rivet on your phone +

+ This QR contains your pairing key. Keep it private. The page detects the connection + automatically. +

+
+ ) : null} + {status.google?.state === "phone_confirmation" && status.google.emoji ? ( +
+

Confirm this emoji in Google Messages on your phone:

+

+ {status.google.emoji} +

+
+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + {status.google?.error && !status.googleSessionPresent ? ( +

+ Google pairing did not complete. Start a new attempt when ready. +

+ ) : null} + {status.checkedAt ? ( +

+ Google authentication checked {new Date(status.checkedAt).toLocaleString()}. This is a + point-in-time check. +

+ ) : null} +
+ ); +} diff --git a/apps/host-selfhost/web/rivet-setup-state.ts b/apps/host-selfhost/web/rivet-setup-state.ts new file mode 100644 index 0000000000..dd8370ae4f --- /dev/null +++ b/apps/host-selfhost/web/rivet-setup-state.ts @@ -0,0 +1,103 @@ +import { Schema } from "effect"; + +export const SetupStatusSchema = Schema.Struct({ + phoneConnected: Schema.Boolean, + phoneEndpoint: Schema.String, + setupEndpoint: Schema.String, + googleConfigured: Schema.Boolean, + googleSessionPresent: Schema.Boolean, + googleAuthenticated: Schema.Boolean, + checkedAt: Schema.NullOr(Schema.Number), + error: Schema.NullOr(Schema.String), + google: Schema.NullOr( + Schema.Struct({ + id: Schema.String, + state: Schema.Literals([ + "starting", + "sign_in", + "phone_confirmation", + "verifying", + "complete", + "failed", + "canceled", + ]), + expiresAt: Schema.Number, + method: Schema.optional(Schema.Literal("local-browser")), + handoff: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), + emoji: Schema.optional(Schema.String), + }), + ), +}); +export type SetupStatus = typeof SetupStatusSchema.Type; +export const decodeSetupStatus = Schema.decodeUnknownSync(SetupStatusSchema); + +export const setupActive = (status: SetupStatus) => + status.google !== null && + ["starting", "sign_in", "phone_confirmation", "verifying"].includes(status.google.state); + +export function localSignInUrl(status: SetupStatus): string | null { + if ( + status.google?.method !== "local-browser" || + status.google.state !== "sign_in" || + !status.google.handoff || + Date.now() >= status.google.expiresAt + ) + return null; + if (!URL.canParse(status.google.handoff) || !URL.canParse(status.setupEndpoint)) return null; + const uri = new URL(status.google.handoff); + const rawEndpoint = uri.searchParams.get("endpoint") ?? ""; + if (!URL.canParse(rawEndpoint)) return null; + const endpoint = new URL(rawEndpoint); + const setup = new URL(status.setupEndpoint); + if ( + setup.protocol !== "https:" || + setup.username || + setup.password || + setup.search || + setup.hash || + setup.pathname !== "/setup" + ) + return null; + return uri.protocol === "rivet-pair:" && + uri.hostname === "google" && + !uri.pathname && + !uri.username && + !uri.password && + !uri.port && + uri.searchParams.size === 2 && + uri.searchParams.get("id") === status.google.id && + /^[a-f0-9]{64}$/.test(uri.hash.slice(1)) && + endpoint.protocol === "https:" && + endpoint.origin === setup.origin && + !endpoint.username && + !endpoint.password && + !endpoint.search && + !endpoint.hash && + endpoint.pathname === "/setup/google/credentials" + ? uri.href + : null; +} + +export function setupInstruction(status: SetupStatus): string { + if ( + (status.google?.state === "sign_in" || status.google?.state === "starting") && + status.google.method === "local-browser" + ) + return "Open Google sign-in on a nearby computer. Use your phone's passkey in Chrome, then confirm the Messages emoji here."; + if (status.google?.state === "phone_confirmation") + return "Open Google Messages on your phone and confirm the matching emoji."; + if (status.google?.state === "sign_in" || status.google?.state === "starting") + return "Google sign-in requires the installed Rivet helper on a nearby computer."; + if (status.google?.state === "verifying") + return "Verifying Google Messages authentication without fetching history."; + if (status.google?.state === "failed" && !status.googleSessionPresent) + return "Google Messages pairing did not finish. Retry when you are ready."; + if (!status.phoneConnected) + return "Connect your phone: scan the pairing QR in Rivet, review the address, then Save and connect."; + if (status.googleConfigured && !status.googleAuthenticated) + return status.googleSessionPresent + ? "Your phone is connected. Verify the saved Google Messages session." + : "Your phone is connected. Connect Google Messages to enable its protocol tools."; + return "Your configured connections are ready."; +} diff --git a/apps/host-selfhost/web/routes/__root.tsx b/apps/host-selfhost/web/routes/__root.tsx index 75b1a55343..c92b49a3a5 100644 --- a/apps/host-selfhost/web/routes/__root.tsx +++ b/apps/host-selfhost/web/routes/__root.tsx @@ -3,6 +3,13 @@ import { useEffect, useState, type ReactNode } from "react"; import { ExecutorProvider } from "@executor-js/react/api/provider"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; +import { IntegrationSetupProvider } from "@executor-js/react/components/integration-setup"; +import { RivetSetupPanel } from "../rivet-setup-panel"; + +const renderIntegrationSetup = (integration: string) => ( + +); + import { ArtifactRendererProvider } from "@executor-js/react/api/artifact-renderer"; import { OrganizationProvider } from "@executor-js/react/api/organization-context"; import { OrgSlugGate } from "@executor-js/react/multiplayer/org-slug-gate"; @@ -179,11 +186,13 @@ function AuthenticatedApp() { to select anyway. */} - {organization ? ( - {gated} - ) : ( - gated - )} + + {organization ? ( + {gated} + ) : ( + gated + )} + diff --git a/e2e/selfhost/rivet-setup-ui.test.ts b/e2e/selfhost/rivet-setup-ui.test.ts new file mode 100644 index 0000000000..529bd2f91d --- /dev/null +++ b/e2e/selfhost/rivet-setup-ui.test.ts @@ -0,0 +1,171 @@ +import { randomBytes } from "node:crypto"; +import { writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { IntegrationSlug } from "@executor-js/sdk/shared"; +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; +const api = composePluginApi([mcpHttpPlugin()] as const); +scenario( + "Rivet setup · QR, cancellation, sign-in and phone confirmation", + { + timeout: 180_000, + skip: process.env.E2E_RIVET_SETUP_URL + ? undefined + : "Requires the native synthetic Rivet fixture described in apps/host-selfhost/docs/rivet.md", + }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeClient } = yield* Api; + const directory = process.env.E2E_RIVET_SETUP_DIRECTORY; + const endpoint = process.env.E2E_RIVET_SETUP_URL; + const configFile = process.env.EXECUTOR_RIVET_SETUP_CONFIG_FILE; + if ( + !directory || + !directory.startsWith("/tmp/rivet-executor-fixture-") || + !endpoint || + !configFile + ) + throw new Error("Native synthetic Rivet fixture is required"); + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const slug = IntegrationSlug.make(`rivet_setup_${randomBytes(4).toString("hex")}`); + const session = yield* Effect.promise(async () => { + const response = await fetch(new URL("/api/auth/get-session", target.baseUrl), { + headers: identity.headers, + }); + return (await response.json()) as { user: { id: string } }; + }); + yield* Effect.gen(function* () { + writeFileSync(join(directory, "owner-id"), session.user.id, { mode: 0o600 }); + writeFileSync( + configFile, + JSON.stringify({ + endpoint: endpoint + "/setup", + tokenFile: join(directory, "setup.token"), + ownerId: session.user.id, + integration: slug, + browserOrigin: new URL(target.baseUrl).origin, + }), + { mode: 0o600 }, + ); + yield* client.mcp.addServer({ + payload: { + name: "Synthetic Rivet setup", + slug, + transport: "remote", + endpoint: endpoint + "/mcp", + remoteTransport: "streamable-http", + headers: { Authorization: "Bearer " + "c".repeat(64) }, + }, + }); + yield* browser.session(identity, async ({ page, step }) => { + await step("Open Rivet setup on the integration page", async () => { + writeFileSync(join(directory, "setup.token"), "d".repeat(64), { mode: 0o600 }); + await page.goto(`/default/integrations/${slug}`, { waitUntil: "domcontentloaded" }); + const retry = page.getByRole("button", { name: "Retry setup check" }); + await retry.waitFor(); + await page.getByRole("alert").waitFor(); + writeFileSync(join(directory, "setup.token"), "b".repeat(64), { mode: 0o600 }); + await retry.click(); + await page.getByRole("heading", { name: "Set up Rivet" }).waitFor(); + await page.getByText("Needs connection", { exact: true }).waitFor(); + }); + await step("Show the private phone QR and detect the phone connecting", async () => { + await page.getByRole("button", { name: "Connect phone", exact: true }).click(); + const qr = page.getByRole("img", { name: "Scan this private QR in Rivet on your phone" }); + await qr.waitFor(); + writeFileSync(join(directory, "connect-phone.synthetic"), "", { mode: 0o600 }); + await page.getByText("Connected", { exact: true }).waitFor(); + await qr.waitFor({ state: "detached" }); + }); + await step("Start local Google sign-in and cancel the attempt", async () => { + await page.getByRole("button", { name: "Connect Google Messages", exact: true }).click(); + await page.getByRole("link", { name: "Sign in with Google passkey" }).waitFor(); + await page.getByRole("button", { name: "Cancel setup", exact: true }).click(); + await page + .getByRole("button", { name: "Connect Google Messages", exact: true }) + .waitFor(); + await page + .getByRole("img", { name: "Interactive isolated Google sign-in" }) + .waitFor({ state: "detached" }); + }); + await step( + "Submit synthetic handoff credentials and wait for phone confirmation", + async () => { + await page + .getByRole("button", { name: "Connect Google Messages", exact: true }) + .click(); + + const link = page.getByRole("link", { name: "Sign in with Google passkey" }); + await link.waitFor(); + const uri = new URL((await link.getAttribute("href"))!); + expect(uri.protocol).toBe("rivet-pair:"); + expect( + await page.getByRole("img", { name: "Interactive isolated Google sign-in" }).count(), + ).toBe(0); + // Synthetic credentials only. Real passkey interaction is a separate + // acceptance gate; never invoke an OS handler in a recorded fixture. + const destination = uri.searchParams.get("endpoint")!; + expect(destination).toBe(endpoint + "/setup/google/credentials"); + const status = await fetch(endpoint + "/setup/status", { + headers: { + Authorization: "Bearer " + "b".repeat(64), + "X-Rivet-Setup-Owner": session.user.id, + }, + }).then((r) => r.json()); + expect(status.setupEndpoint).toBe(endpoint + "/setup"); + expect(new URL(status.phoneEndpoint).port).not.toBe(new URL(destination).port); + const response = await fetch(destination, { + method: "POST", + headers: { + Authorization: `Bearer ${uri.hash.slice(1)}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + id: uri.searchParams.get("id"), + cookies: Object.fromEntries( + ["SID", "HSID", "OSID", "SSID", "APISID", "SAPISID"].map((name) => [ + name, + "synthetic", + ]), + ), + }), + }); + expect(response.status).toBe(202); + await page.getByLabel("Pairing confirmation emoji").waitFor(); + expect(await page.getByLabel("Pairing confirmation emoji").textContent()).toBe("🦊"); + }, + ); + await step("Detect phone confirmation and verify Google authentication", async () => { + writeFileSync(join(directory, "gmessages", "confirm.synthetic"), "", { mode: 0o600 }); + await page.getByText("Authenticated", { exact: true }).waitFor(); + await page.getByText(/Google authentication checked/).waitFor(); + expect( + await page.getByRole("img", { name: "Interactive isolated Google sign-in" }).count(), + ).toBe(0); + await page.getByRole("button", { name: "Check connections", exact: true }).click(); + await page.getByText("Authenticated", { exact: true }).waitFor(); + }); + }); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + yield* client.mcp.removeServer({ params: { slug } }).pipe(Effect.ignore); + for (const file of [ + configFile, + join(directory, "owner-id"), + join(directory, "connect-phone.synthetic"), + join(directory, "gmessages", "confirm.synthetic"), + ]) + rmSync(file, { force: true }); + writeFileSync(join(directory, "setup.token"), "b".repeat(64), { mode: 0o600 }); + }), + ), + ); + }), +); diff --git a/packages/core/sdk/src/health-check.ts b/packages/core/sdk/src/health-check.ts index 9e39047d55..839f4af4ac 100644 --- a/packages/core/sdk/src/health-check.ts +++ b/packages/core/sdk/src/health-check.ts @@ -159,6 +159,8 @@ export const HealthCheckCandidateParameter = Schema.Struct({ name: Schema.String, /** Where the parameter is carried (e.g. "query", "path", "header"). */ location: Schema.String, + /** JSON editing preserves MCP scalar, array, and object argument types. */ + valueFormat: Schema.optional(Schema.Literal("json")), required: Schema.Boolean, description: Schema.optional(Schema.String), }); diff --git a/packages/plugins/mcp/src/react/McpAccountsPanel.tsx b/packages/plugins/mcp/src/react/McpAccountsPanel.tsx index bb33a8a999..f0475e94c6 100644 --- a/packages/plugins/mcp/src/react/McpAccountsPanel.tsx +++ b/packages/plugins/mcp/src/react/McpAccountsPanel.tsx @@ -5,7 +5,9 @@ import * as Exit from "effect/Exit"; import { IntegrationSlug } from "@executor-js/sdk/shared"; import type { IntegrationAccountHandoff } from "@executor-js/sdk/client"; +import { HealthCheckEditor } from "@executor-js/react/components/health-check-editor"; import { AccountsSection } from "@executor-js/react/components/accounts-section"; +import { IntegrationSetupSection } from "@executor-js/react/components/integration-setup"; import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; import { useCustomMethodActions, @@ -100,6 +102,7 @@ export default function McpAccountsPanel(props: { return (
+ +
); } diff --git a/packages/plugins/mcp/src/sdk/health-candidates.test.ts b/packages/plugins/mcp/src/sdk/health-candidates.test.ts new file mode 100644 index 0000000000..cc43abb81a --- /dev/null +++ b/packages/plugins/mcp/src/sdk/health-candidates.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "@effect/vitest"; +import { compatibleHealthCandidates, type McpHealthOperation } from "./health-candidates"; +const operation = ( + name: string, + inputSchema: unknown = { type: "object", properties: {} }, +): McpHealthOperation => ({ + name, + inputSchema, + outputSchema: undefined, + readOnly: true, + destructive: false, +}); +describe("integration-wide MCP health candidates", () => { + it("intersects catalogs and refuses missing, failed, destructive, or incompatible connections", () => { + const common = operation("health"); + expect( + compatibleHealthCandidates([ + [common, operation("only_a")], + [common, operation("only_b")], + ]).map((c) => c.operation), + ).toEqual(["health"]); + for (const peer of [ + { ...common, readOnly: false }, + { ...common, destructive: true }, + { ...common, inputSchema: { type: "object", properties: { limit: { type: "number" } } } }, + { ...common, outputSchema: { type: "string" } }, + ]) + expect(compatibleHealthCandidates([[common], [peer]])).toEqual([]); + expect(compatibleHealthCandidates([[common], []])).toEqual([]); + expect(compatibleHealthCandidates([])).toEqual([]); + }); + it("ignores object key order and exposes typed required and optional arguments", () => { + const first = operation("health", { + type: "object", + properties: { + limit: { type: "number" }, + body: { type: "string" }, + options: { type: "object" }, + }, + required: ["limit", "body"], + }); + const peer = { + ...first, + inputSchema: { + required: ["limit", "body"], + properties: { + options: { type: "object" }, + body: { type: "string" }, + limit: { type: "number" }, + }, + type: "object", + }, + }; + const candidates = compatibleHealthCandidates([[first], [peer]]); + expect(candidates).toHaveLength(1); + expect(candidates[0]?.requiredArgCount).toBe(2); + expect(candidates[0]?.parameters).toContainEqual({ + name: "options", + location: "argument", + required: false, + valueFormat: "json", + }); + expect(candidates[0]?.parameters).toContainEqual({ + name: "body", + location: "argument", + required: true, + valueFormat: "json", + }); + }); +}); diff --git a/packages/plugins/mcp/src/sdk/health-candidates.ts b/packages/plugins/mcp/src/sdk/health-candidates.ts new file mode 100644 index 0000000000..7b4a9ced88 --- /dev/null +++ b/packages/plugins/mcp/src/sdk/health-candidates.ts @@ -0,0 +1,62 @@ +import { isDeepStrictEqual } from "node:util"; +import { Option, Schema } from "effect"; +import { projectResponseFields, type HealthCheckCandidate } from "@executor-js/sdk/core"; + +export interface McpHealthOperation { + readonly name: string; + readonly description?: string; + readonly inputSchema: unknown; + readonly outputSchema: unknown; + readonly readOnly: boolean; + readonly destructive: boolean; +} +const Input = Schema.Struct({ + properties: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + required: Schema.optional(Schema.Array(Schema.String)), +}); +const decodeInput = Schema.decodeUnknownOption(Input); + +/** A single integration spec runs against every connection, so a union is unsafe. */ +export function compatibleHealthCandidates( + catalogs: readonly (readonly McpHealthOperation[])[], +): readonly HealthCheckCandidate[] { + const first = catalogs[0]; + if (!first) return []; + return first.flatMap((operation) => { + if (!operation.readOnly || operation.destructive) return []; + if ( + catalogs.some((catalog) => { + const peer = catalog.find((entry) => entry.name === operation.name); + return ( + !peer || + !peer.readOnly || + peer.destructive || + !isDeepStrictEqual(peer.inputSchema, operation.inputSchema) || + !isDeepStrictEqual(peer.outputSchema, operation.outputSchema) + ); + }) + ) + return []; + const schema = Option.getOrUndefined(decodeInput(operation.inputSchema)); + if (!schema) return []; + const properties = schema.properties ?? {}; + const required = schema.required ?? []; + if (required.some((name) => !(name in properties))) return []; + return [ + { + operation: operation.name, + method: "mcp", + destructive: false, + requiredArgCount: required.length, + summary: operation.description, + parameters: Object.keys(properties).map((name) => ({ + name, + location: "argument", + required: required.includes(name), + valueFormat: "json" as const, + })), + responseFields: projectResponseFields(operation.outputSchema), + }, + ]; + }); +} diff --git a/packages/plugins/mcp/src/sdk/health-probe.test.ts b/packages/plugins/mcp/src/sdk/health-probe.test.ts new file mode 100644 index 0000000000..b6bdc4d12c --- /dev/null +++ b/packages/plugins/mcp/src/sdk/health-probe.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "@effect/vitest"; +import { classifyMcpHealthProbe } from "./health-probe"; + +describe("configured MCP health probe", () => { + it("does not confuse successful transport with an execution error", () => { + const result = classifyMcpHealthProbe({ + isError: true, + content: [{ type: "text", text: "GM_PAIRING_REQUIRED_OR_INVALID_SESSION" }], + }); + expect(result.status).toBe("degraded"); + expect(result.detail).toContain("PAIRING_REQUIRED"); + expect(result.responseSample).toBeUndefined(); + }); + it("refuses malformed responses and never persists free-form error data", () => { + expect(classifyMcpHealthProbe({}).status).toBe("degraded"); + const result = classifyMcpHealthProbe({ + isError: true, + content: [{ type: "text", text: "synthetic private account and message" }], + }); + expect(JSON.stringify(result)).not.toContain("private account"); + }); + it("uses structured output for identity and redacts sample credentials", () => { + const result = classifyMcpHealthProbe( + { + content: [], + structuredContent: { name: "synthetic", token: "secret", innocent: "secret" }, + }, + "name", + ["secret"], + ); + expect(result.status).toBe("healthy"); + expect(result.identity).toBe("synthetic"); + expect(JSON.stringify(result)).not.toContain('"value":"secret"'); + }); + it("extracts identity from a later JSON text block and preserves structured-output precedence", () => { + const content = [ + { type: "text", text: "synthetic explanation" }, + { type: "text", text: '{"email":"synthetic@example.test"}' }, + ]; + expect(classifyMcpHealthProbe({ content }, "email")).toMatchObject({ + identity: "synthetic@example.test", + responseSample: [{ path: "email", value: "synthetic@example.test" }], + }); + expect( + classifyMcpHealthProbe( + { content, structuredContent: { email: "structured@example.test" } }, + "email", + ).identity, + ).toBe("structured@example.test"); + }); + it("accepts Rivet's bounded JSON output without persisting unrelated content", () => { + const result = classifyMcpHealthProbe({ + content: [{ type: "text", text: '{"authenticated":true,"scope":"point_in_time"}' }], + }); + expect(result.status).toBe("healthy"); + expect(result.responseSample).toContainEqual({ path: "authenticated", value: "true" }); + }); +}); diff --git a/packages/plugins/mcp/src/sdk/health-probe.ts b/packages/plugins/mcp/src/sdk/health-probe.ts new file mode 100644 index 0000000000..be9e4643fe --- /dev/null +++ b/packages/plugins/mcp/src/sdk/health-probe.ts @@ -0,0 +1,66 @@ +import { Option, Predicate, Schema } from "effect"; +import { + extractIdentity, + extractResponseFields, + type HealthCheckResult, +} from "@executor-js/sdk/core"; + +const Envelope = Schema.Struct({ + content: Schema.Array(Schema.Unknown), + isError: Schema.optional(Schema.Boolean), + structuredContent: Schema.optional(Schema.Unknown), +}); +const Text = Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }); +const decode = Schema.decodeUnknownOption(Envelope); +const decodeText = Schema.decodeUnknownOption(Text); +const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); + +/** Persist neither arbitrary tool errors nor unstructured response content. */ +export const classifyMcpHealthProbe = ( + raw: unknown, + identityField?: string, + secrets: readonly string[] = [], +): HealthCheckResult => { + const checkedAt = Date.now(); + const envelope = Option.getOrUndefined(decode(raw)); + if (!envelope) + return { + status: "degraded", + checkedAt, + reason: "probe_failed", + detail: "Malformed MCP probe response.", + }; + const scrub = (value: string) => + secrets.reduce((text, secret) => text.replaceAll(secret, "[redacted]"), value); + if (envelope.isError === true) { + // Bounded machine codes are useful; free-form errors can contain account + // data. Never persist them or response samples from failed probes. + const texts = envelope.content + .map((item) => Option.getOrUndefined(decodeText(item))?.text) + .filter(Predicate.isNotUndefined); + const code = + texts.length === 1 && /^[A-Z][A-Z0-9_]{1,79}$/.test(texts[0] ?? "") + ? scrub(texts[0]!) + : undefined; + return { + status: "degraded", + checkedAt, + reason: "probe_failed", + detail: code ? `Health probe failed: ${code}.` : "MCP health probe returned an error.", + }; + } + const json = envelope.content + .map((item) => Option.getOrUndefined(decodeText(item))) + .filter(Predicate.isNotUndefined) + .map((item) => decodeJson(item.text)) + .find(Option.isSome); + const body = envelope.structuredContent ?? (json && Option.getOrUndefined(json)); + const identity = extractIdentity(body, identityField); + return { + status: "healthy", + checkedAt, + detail: "Configured MCP probe succeeded (point-in-time check).", + ...(identity === undefined ? {} : { identity: scrub(identity) }), + responseSample: extractResponseFields(body, { scrub }), + }; +}; diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index 487874677b..a72b15777c 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -64,7 +64,11 @@ const jsonRpcResult = (request: JsonRpcRequest, result: unknown) => // scenario supplies that branch via a `CallToolResponder`. type CallToolResponder = (rpc: JsonRpcRequest) => ReturnType; -const callToolFixtureResponse = (rpc: JsonRpcRequest, callTool: CallToolResponder) => { +const callToolFixtureResponse = ( + rpc: JsonRpcRequest, + callTool: CallToolResponder, + readOnly = false, +) => { if (rpc.method === "initialize") { return jsonRpcResult(rpc, { protocolVersion: "2025-06-18", @@ -80,6 +84,7 @@ const callToolFixtureResponse = (rpc: JsonRpcRequest, callTool: CallToolResponde tools: [ { name: "explode", + annotations: { readOnlyHint: readOnly }, description: "Returns a failure from tools/call", inputSchema: { type: "object", properties: {} }, }, @@ -92,7 +97,7 @@ const callToolFixtureResponse = (rpc: JsonRpcRequest, callTool: CallToolResponde return HttpServerResponse.text("Unexpected JSON-RPC method", { status: 400 }); }; -const serveCallToolServer = (callTool: CallToolResponder) => +const serveCallToolServer = (callTool: CallToolResponder, readOnly = false) => serveTestHttpApp((request) => Effect.gen(function* () { if (request.method === "GET") { @@ -102,7 +107,7 @@ const serveCallToolServer = (callTool: CallToolResponder) => const body = yield* request.text.pipe(Effect.orDie); return Option.match(decodeJsonRpcRequest(body), { onNone: () => HttpServerResponse.text("Invalid JSON-RPC fixture request", { status: 400 }), - onSome: (rpc) => callToolFixtureResponse(rpc, callTool), + onSome: (rpc) => callToolFixtureResponse(rpc, callTool, readOnly), }); }), ); @@ -256,10 +261,11 @@ const seedCallToolExecutor = (input: { * transport gets a real authProvider — the production OAuth path where the * SDK's own challenge handling would otherwise swallow scope signals. */ oauth?: boolean; + readOnly?: boolean; }) => Effect.acquireRelease( Effect.gen(function* () { - const server = yield* serveCallToolServer(input.callTool); + const server = yield* serveCallToolServer(input.callTool, input.readOnly); const config = makeTestConfig({ plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, }); @@ -999,6 +1005,77 @@ describe("mcpPlugin", () => { ); } + for (const status of [401, 403, 500] as const) { + it.effect(`classifies HTTP ${status} at health tools/call without replaying it`, () => + Effect.scoped( + Effect.gen(function* () { + let calls = 0; + const slug = `health_call_status_${status}`; + const { executor } = yield* seedCallToolExecutor({ + slug, + readOnly: true, + callTool: (rpc) => { + calls++; + return httpStatusCallTool(status)(rpc); + }, + }); + const result = yield* executor.connections.validate({ + owner: "org", + integration: IntegrationSlug.make(slug), + template: TEMPLATE, + value: "", + spec: { operation: "explode" }, + }); + expect(result).toMatchObject({ + status: status === 500 ? "degraded" : "expired", + httpStatus: status, + reason: "upstream_status", + }); + expect(JSON.stringify(result)).not.toContain("do-not-leak"); + expect(calls).toBe(1); + }), + ), + ); + } + it.effect( + "reports a health tools/call scope shortfall without asking for the same grant again", + () => + Effect.scoped( + Effect.gen(function* () { + const slug = "health_call_scope"; + let calls = 0; + const { executor } = yield* seedCallToolExecutor({ + slug, + readOnly: true, + oauth: true, + callTool: () => { + calls++; + return HttpServerResponse.text("private synthetic detail", { + status: 403, + headers: { + "www-authenticate": 'Bearer error="insufficient_scope", scope="health.read"', + }, + }); + }, + }); + const result = yield* executor.connections.validate({ + owner: "org", + integration: IntegrationSlug.make(slug), + template: AuthTemplateSlug.make("oauth2"), + value: "static-access-token", + spec: { operation: "explode" }, + }); + expect(result).toMatchObject({ + status: "misconfigured", + httpStatus: 403, + reason: "upstream_status", + }); + expect(result.detail).toContain("additional scope"); + expect(JSON.stringify(result)).not.toContain("private synthetic detail"); + expect(calls).toBe(1); + }), + ), + ); // The lone-401 replay above is restricted to read-only methods. A // `tools/call` may have executed its side effect before the server answered // 401 (HTTP gives no such guarantee), so the adapter must never re-send it diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 079b89dce5..250bab9293 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -1,3 +1,4 @@ +import { compatibleHealthCandidates, type McpHealthOperation } from "./health-candidates"; import { Effect, Layer, Option, Result, Schema } from "effect"; import type { HttpClient } from "effect/unstable/http"; @@ -7,6 +8,8 @@ import { callToolResultJsonSchema } from "./call-tool-result-schema.gen"; import { authToolFailure, + ElicitationDeclinedError, + ToolAddress, AuthTemplateSlug, ConnectionName, definePlugin, @@ -53,6 +56,7 @@ import { McpOAuthReauthorizationRequired, McpToolDiscoveryError, } from "./errors"; +import { classifyMcpHealthProbe } from "./health-probe"; import { invokeMcpTool, isUnknownToolMessage } from "./invoke"; import { deriveMcpNamespace, type McpToolManifestEntry } from "./manifest"; import { mcpPresets } from "./presets"; @@ -1931,14 +1935,39 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { return out; }), - // Liveness-only health check. MCP has no usable identity source (no - // id_token/userinfo, no standard whoami), so this answers "is this - // credential still alive?" by dialing the server and listing tools (the same - // path resolveTools uses); identity stays the user-supplied connection label. - // Only checkHealth is implemented (no candidates/describe/set), so the - // operation/identity editor stays hidden while the status dot + "Check now" - // light up. - checkHealth: ({ ctx, credential }) => + // Discovery enumerates candidates without invoking them. + listHealthCheckCandidates: ({ ctx, integration }) => + Effect.gen(function* () { + const connections = yield* ctx.connections.list({ integration: integration.slug }); + const catalogs: McpHealthOperation[][] = []; + for (const connection of connections) { + const tools = yield* ctx.connections + .refresh({ + owner: connection.owner, + integration: integration.slug, + name: connection.name, + }) + .pipe(Effect.orElseSucceed(() => [])); + catalogs.push( + tools.map((tool) => { + const stamp = readStamp(tool.annotations); + return { + name: stamp?.toolName ?? String(tool.name), + description: tool.description, + inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema, + readOnly: stamp?.upstream?.readOnlyHint === true, + destructive: stamp?.upstream?.destructiveHint === true, + }; + }), + ); + } + return compatibleHealthCandidates(catalogs); + }), + + // Without a spec, preserve discovery health. With one, invoke only that + // explicitly configured read-only operation and honor execution errors. + checkHealth: ({ ctx, credential, spec }) => Effect.gen(function* () { const parsed = parseMcpIntegrationConfig(credential.config); if (!parsed) { @@ -1978,9 +2007,49 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { ); return yield* discoverToolsFromInput(connector).pipe( - Effect.map( - () => - ({ status: "healthy" as const, checkedAt: Date.now() }) satisfies HealthCheckResult, + Effect.flatMap(({ manifest }) => + Effect.gen(function* () { + if (!spec) + return { + status: "healthy" as const, + checkedAt: Date.now(), + detail: "MCP discovery only; no upstream session probe configured.", + } satisfies HealthCheckResult; + const tool = manifest.tools.find((tool) => tool.toolName === spec.operation); + if ( + !tool || + tool.annotations?.readOnlyHint !== true || + tool.annotations.destructiveHint === true + ) + return { + status: "degraded" as const, + checkedAt: Date.now(), + detail: + "Configured MCP health probe is unavailable or not read-only. Update the server or choose a different probe.", + reason: "probe_failed" as const, + } satisfies HealthCheckResult; + const raw = yield* invokeMcpTool({ + toolId: tool.toolId, + toolName: tool.toolName, + args: spec.args ?? {}, + transport: parsed.transport, + connector: createMcpConnector(connector), + elicit: () => + Effect.fail( + new ElicitationDeclinedError({ + address: ToolAddress.make(`health.${tool.toolId}`), + action: "decline", + }), + ), + }); + return classifyMcpHealthProbe( + raw, + spec.identityField, + Object.values(credential.values).filter( + (v): v is string => typeof v === "string" && v.length > 0, + ), + ); + }), ), Effect.catchTag("McpToolDiscoveryError", (error) => Effect.succeed({ @@ -1993,6 +2062,32 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { ), ); }).pipe( + Effect.catchTag("McpInvocationError", (error) => + Effect.succeed({ + status: + error.insufficientScope === true + ? ("misconfigured" as const) + : error.status === 401 || error.status === 403 + ? ("expired" as const) + : ("degraded" as const), + ...(error.status !== undefined ? { httpStatus: error.status } : {}), + checkedAt: Date.now(), + detail: + error.insufficientScope === true + ? "MCP health probe requires additional scope. Update the credential grants." + : "MCP health tool invocation failed.", + reason: + error.status !== undefined ? ("upstream_status" as const) : ("probe_failed" as const), + } satisfies HealthCheckResult), + ), + Effect.catchTag("McpOAuthReauthorizationRequired", () => + Effect.succeed({ + status: "expired" as const, + checkedAt: Date.now(), + detail: "MCP OAuth reauthorization required.", + reason: "credential_refresh_rejected" as const, + } satisfies HealthCheckResult), + ), // buildConnectorInput rejects (e.g. stdio disabled / missing config). Effect.catchTag("McpConnectionError", (error) => Effect.succeed({ diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index 222dd0f682..d7404dbb07 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -1,3 +1,4 @@ +import { isJsonHealthArgument, parseHealthCheckArgs } from "../lib/health-check-args"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; import * as Exit from "effect/Exit"; @@ -1308,7 +1309,11 @@ function RequestCheckPanel(props: { })), [candidates], ); - const requiredParams = (selected?.parameters ?? []).filter((p) => p.required); + const argumentParams = (selected?.parameters ?? []).filter( + (p) => p.required || p.valueFormat === "json", + ); + const usesJsonArguments = argumentParams.some((p) => p.valueFormat === "json"); + const validArgs = parseHealthCheckArgs(props.args, selected?.parameters).ok; const healthy = result?.status === "healthy"; // Identity-looking fields first, then response order; capped so a chatty // response can't blow the modal up (the hidden tail adds nothing, since the @@ -1358,29 +1363,51 @@ function RequestCheckPanel(props: { - {/* Required args, only when the chosen call needs them. */} - {!configuredOperation && requiredParams.length > 0 ? ( + {/* Required parameters and editable MCP JSON arguments. */} + {!configuredOperation && argumentParams.length > 0 ? (
- {requiredParams.map((param) => ( + {argumentParams.map((param) => (
) => props.onArgChange(param.name, e.target.value) } - placeholder={param.description ?? "required"} + placeholder={param.description ?? (param.required ? "required" : "optional")} disabled={props.validating} className="h-7 font-mono text-xs" />
))} + {usesJsonArguments ? ( +

+ Use JSON for MCP arguments, including quotes around text. +

+ ) : null} + {!validArgs ? ( +

+ Enter a valid JSON argument. +

+ ) : null}
) : null} @@ -1737,7 +1764,8 @@ function AddAccountModalView(props: AddAccountModalProps) { const canCheckKey = !isOAuth && !isNoAuth && (hasHealthCheck || healthCheckCandidates.length > 0); // Pick mode's probe is runnable once an operation is chosen and its required // args are filled. - const hcCandidateReady = hcOperation.length > 0 && !hcMissingRequired; + const hcParsedArgs = parseHealthCheckArgs(hcArgs, hcSelected?.parameters); + const hcCandidateReady = hcOperation.length > 0 && !hcMissingRequired && hcParsedArgs.ok; // The distinct credential inputs the selected method needs — one per variable // across its placements. A single-input method yields one field (`token`); a @@ -2172,12 +2200,10 @@ function AddAccountModalView(props: AddAccountModalProps) { const candidateHealthSpec = (): HealthCheckSpec | null => { if (!hcCandidateReady) return null; - const argEntries = Object.entries(hcArgs) - .map(([key, value]) => [key, value.trim()] as const) - .filter(([, value]) => value.length > 0); + if (!hcParsedArgs.ok) return null; return { operation: hcOperation, - ...(argEntries.length > 0 ? { args: Object.fromEntries(argEntries) } : {}), + ...(Object.keys(hcParsedArgs.args).length > 0 ? { args: hcParsedArgs.args } : {}), }; }; diff --git a/packages/react/src/components/health-check-editor.tsx b/packages/react/src/components/health-check-editor.tsx index f3c41257d5..7adcb3aa2a 100644 --- a/packages/react/src/components/health-check-editor.tsx +++ b/packages/react/src/components/health-check-editor.tsx @@ -6,6 +6,7 @@ import { rankResponseSample, type AuthTemplateSlug, type HealthCheckCandidate, + type HealthCheckCandidateParameter, type HealthCheckResult, type HealthCheckSpec, type IntegrationSlug, @@ -22,7 +23,11 @@ import { import { healthCheckWriteKeys } from "../api/reactivity-keys"; import { messageFromExit } from "../api/error-reporting"; import { HEALTH_STATUS_LABEL, HEALTH_TEXT_CLASS } from "../lib/health-display"; -import { formatHealthCheckArgs, parseHealthCheckArgs } from "../lib/health-check-args"; +import { + formatHealthCheckArgs, + parseHealthCheckArgs, + isJsonHealthArgument, +} from "../lib/health-check-args"; import { Button } from "./button"; import { FreeformCombobox, type FreeformComboboxOption } from "./combobox"; import { Input } from "./input"; @@ -143,7 +148,10 @@ function HealthCheckConfigFields(props: { ); const pinnedParams = useMemo( - () => (selected?.parameters ?? []).filter((p) => p.required || p.location === "body"), + () => + (selected?.parameters ?? []).filter( + (p) => p.required || p.location === "body" || p.valueFormat === "json", + ), [selected], ); @@ -194,20 +202,26 @@ function HealthCheckConfigFields(props: { {param.required ? ", required" : ", optional"}) - {param.location === "body" ? ( + {isJsonHealthArgument(param.name, selected?.parameters) ? ( <>