diff --git a/.claude/rules/sim-url-state.md b/.claude/rules/sim-url-state.md index a65acd67004..9312fe72c9b 100644 --- a/.claude/rules/sim-url-state.md +++ b/.claude/rules/sim-url-state.md @@ -34,7 +34,7 @@ Put state in the URL **only** when it is *all* of: shareable, deep-linkable, boo ## Anti-patterns (forbidden) - Direct `useSearchParams().get(...)` or `new URLSearchParams(window.location.search)` to **read** state. -- Hand-built query strings + `router.replace`/`router.push` to **mutate** state. +- Hand-built query strings + `router.replace`/`router.push` to **mutate** state. **If the target path equals the current path, it is a query mutation, not a navigation** — even when written as a full path template. Re-serializing the path by hand is lossy by construction: it drops every param the template forgets. Use the nuqs setter (`setParams({ key: null }, { history: 'replace', scroll: false })`) — `null` always removes the key, and only the params you name are touched. Both options are already nuqs defaults (see "Conventions"); write them explicitly because a group whose shared options set `history: 'push'` (e.g. `filesUrlKeys`) would otherwise push a back-stack entry for a strip. - `window.history.replaceState`/`pushState` to mutate a param. - Duplicating URL state into a store and syncing it with effects / `popstate` listeners. - High-frequency or large state in the URL (cursor, pan/zoom, un-debounced keystrokes, big JSON blobs). @@ -44,7 +44,7 @@ These reads/mutations are **not** anti-patterns and stay as-is: - **Outbound URL builders** — `new URLSearchParams({...})` to construct a `href`, a download endpoint, an external WebSocket/API URL, or a `window.open(_, '_blank')` destination. - **Route navigations** — `router.push('/path/[id]?folderId=x')` that changes the route *path*, not just the current query. A nuqs setter only mutates the query on the current path; cross-path navigation stays on `router`. -- **Read-once auth / redirect signals** — `token`, `callbackUrl`, `redirect`, `error`, `invite_flow`, `upgraded`, `redirect_workflow`, etc. These are navigation signals consumed once (often read-then-strip), not synced view-state. Leave them on `useSearchParams`. +- **Read-once auth / redirect signals** — `token`, `callbackUrl`, `redirect`, `error`, `invite_flow`, `new` (invite signup flow), `upgraded`, `redirect_workflow`, etc. These are navigation signals consumed once (often read-then-strip), not synced view-state. Leave them on `useSearchParams`. Key names are per-surface: files' `new` is a genuine nuqs param (`files/search-params.ts`), while invite's `new` is a one-shot signup signal. ## Per-feature `search-params.ts` — single source of truth @@ -128,7 +128,22 @@ If a client param must be re-read server-side after a change, set `shallow: fals ## Suspense boundary -`useQueryState`/`useQueryStates` read `useSearchParams` internally, so any client component using them must sit under a `` boundary (Next.js requirement). Wrap the page entry with a real-chrome fallback so a suspend never flashes a blank frame — see `apps/sim/app/workspace/[workspaceId]/files/page.tsx`. +`useQueryState`/`useQueryStates` read `useSearchParams` internally, so any client component using them must sit under a `` boundary (Next.js requirement). Wrap the page entry with a real-chrome fallback so a suspend never flashes a blank frame. + +**Never `fallback={null}` on a page entry.** The route's co-located `loading.tsx` default export *is* the correct fallback — one skeleton serves both the route-level navigation transition (which Next renders automatically) and the in-page suspend (which this boundary renders). If the segment has no `loading.tsx`, add one; the route transition needs it anyway. Import it absolutely (`sim-imports.md`): + +```typescript +import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base' +import KnowledgeBaseLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/loading' + +}> + + +``` + +Reference: `apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx`. + +This applies to **page entries**. An inner `` wrapping a `lazy()` component is the exception: there `fallback={null}` is correct, precisely so the suspend resolves at the nearest boundary instead of flashing the whole route — see `sim-imports.md`, "Code-splitting through barrels". ## Debounced text inputs diff --git a/apps/docs/content/docs/en/platform/self-hosting/authentication.mdx b/apps/docs/content/docs/en/platform/self-hosting/authentication.mdx index 15bcd34c7f7..d997438d09b 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/authentication.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/authentication.mdx @@ -84,7 +84,7 @@ See the [SSO guide](/platform/enterprise/sso) for identity-provider setup and th | Variable | Effect | |---|---| -| `DISABLE_REGISTRATION=true` | Blocks email/password registration | +| `DISABLE_REGISTRATION=true` | Blocks all new accounts — email/password, email OTP, and social sign-in. Only existing accounts can sign in, including to accept a workspace invitation. SSO is unaffected | | `DISABLE_EMAIL_SIGNUP=true` | Blocks new email/password registrations; existing email login keeps working | | `ALLOWED_LOGIN_DOMAINS` | Comma-separated domain allowlist, e.g. `acme.com,acme.co.uk`. Gates email sign-**in** as well as signup | | `ALLOWED_LOGIN_EMAILS` | Comma-separated address allowlist, applied the same way | @@ -93,7 +93,9 @@ See the [SSO guide](/platform/enterprise/sso) for identity-provider setup and th | `BLOCKED_EMAIL_MX_HOSTS` | MX-host substrings to block; used only with the above | - These controls gate the **email/password** path. A first-time sign-in through Google, GitHub, or Microsoft creates an account through the social provider and is not filtered by them. If you need a hard boundary, disable the social providers you have not vetted (`DISABLE_GOOGLE_AUTH`, `DISABLE_GITHUB_AUTH`, `DISABLE_MICROSOFT_AUTH`) or restrict membership at the identity provider and use SSO. + `ALLOWED_LOGIN_DOMAINS`, `ALLOWED_LOGIN_EMAILS`, and `SIGNUP_MX_VALIDATION_ENABLED` gate the **email/password** path only. A first-time sign-in through Google, GitHub, or Microsoft creates an account through the social provider and is not filtered by them. To restrict who may sign in through a social provider, disable the ones you have not vetted (`DISABLE_GOOGLE_AUTH`, `DISABLE_GITHUB_AUTH`, `DISABLE_MICROSOFT_AUTH`) or restrict membership at the identity provider and use SSO. + + `DISABLE_REGISTRATION` and `BLOCKED_SIGNUP_DOMAINS` apply to every path, social included. For a company deployment, the usual pairing is domain-restricted signup plus SSO: diff --git a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx index 8a2f01bf27f..3835925ca00 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx @@ -107,7 +107,7 @@ See [Authentication](/platform/self-hosting/authentication). | Variable | Description | |----------|-------------| -| `DISABLE_REGISTRATION` | Set `true` to disable new user signups entirely | +| `DISABLE_REGISTRATION` | Set `true` to block all new accounts, including social sign-in. Invitations still work for people who already have an account. SSO is unaffected | | `DISABLE_EMAIL_SIGNUP` | Block new email/password registrations; existing email login keeps working | | `ALLOWED_LOGIN_DOMAINS` | Restrict signups to domains (comma-separated) | | `ALLOWED_LOGIN_EMAILS` | Restrict signups to specific emails (comma-separated) | diff --git a/apps/sim/app/(auth)/auth-redirect.test.ts b/apps/sim/app/(auth)/auth-redirect.test.ts index e4b9e25b3df..93a94dd6eae 100644 --- a/apps/sim/app/(auth)/auth-redirect.test.ts +++ b/apps/sim/app/(auth)/auth-redirect.test.ts @@ -2,7 +2,11 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { buildAuthCrossLink, resolvePostSignupDestination } from '@/app/(auth)/auth-redirect' +import { + buildAuthCrossLink, + resolveAuthRedirect, + resolvePostSignupDestination, +} from '@/app/(auth)/auth-redirect' describe('resolvePostSignupDestination', () => { it('routes to the verify hop when verification is enforceable', () => { @@ -56,4 +60,50 @@ describe('buildAuthCrossLink', () => { '/signup' ) }) + + it('marks a new user so the invite page leads with account creation', () => { + expect( + buildAuthCrossLink('/signup', { + callbackUrl: '/invite/abc', + isInviteFlow: true, + isNewUser: true, + }) + ).toBe('/signup?invite_flow=true&callbackUrl=%2Finvite%2Fabc&new=true') + }) + + it('omits the new-user marker by default', () => { + expect(buildAuthCrossLink('/signup', { callbackUrl: null, isInviteFlow: true })).not.toContain( + 'new=true' + ) + }) +}) + +describe('resolveAuthRedirect', () => { + const NONE = { redirect: null, callbackUrl: null, inviteFlow: null } + + it('prefers redirect over callbackUrl', () => { + expect(resolveAuthRedirect({ ...NONE, redirect: '/a', callbackUrl: '/b' }).rawCallbackUrl).toBe( + '/a' + ) + }) + + it('falls through an empty redirect to callbackUrl', () => { + expect( + resolveAuthRedirect({ ...NONE, redirect: '', callbackUrl: '/invite/abc' }).rawCallbackUrl + ).toBe('/invite/abc') + }) + + it('reports no destination when nothing was carried', () => { + expect(resolveAuthRedirect(NONE)).toEqual({ rawCallbackUrl: '', isInviteFlow: false }) + }) + + it('treats an invitation destination as an invite flow without the flag', () => { + expect(resolveAuthRedirect({ ...NONE, callbackUrl: '/invite/abc' }).isInviteFlow).toBe(true) + }) + + it('honors the explicit flag when the destination is unrelated', () => { + expect( + resolveAuthRedirect({ ...NONE, callbackUrl: '/workspace', inviteFlow: 'true' }).isInviteFlow + ).toBe(true) + }) }) diff --git a/apps/sim/app/(auth)/auth-redirect.ts b/apps/sim/app/(auth)/auth-redirect.ts index 0cfd1310b22..269528c0bb1 100644 --- a/apps/sim/app/(auth)/auth-redirect.ts +++ b/apps/sim/app/(auth)/auth-redirect.ts @@ -43,10 +43,43 @@ export function resolvePostSignupDestination({ return redirectUrl ? { kind: 'redirect', url: redirectUrl } : { kind: 'workspace' } } +/** The raw redirect-carrying params, as read from a URL on client or server. */ +interface AuthRedirectParams { + redirect: string | null + callbackUrl: string | null + inviteFlow: string | null +} + +/** + * The post-auth destination a visitor arrived with, and whether they are mid + * invitation. + * + * `redirect` wins over `callbackUrl` — both spellings are in circulation. The + * invite flow is inferred from the destination as well as the explicit flag, so + * a link that lost `invite_flow` still reads as an invitation. + * + * Shared so the signup form and the registration-disabled page cannot drift on + * which param wins; both feed the result to {@link buildAuthCrossLink}. The + * caller validates — this function does not, so that a client can log the + * rejection it already reports. + */ +export function resolveAuthRedirect({ redirect, callbackUrl, inviteFlow }: AuthRedirectParams): { + rawCallbackUrl: string + isInviteFlow: boolean +} { + const rawCallbackUrl = redirect || callbackUrl || '' + return { + rawCallbackUrl, + isInviteFlow: inviteFlow === 'true' || rawCallbackUrl.startsWith('/invite/'), + } +} + interface AuthCrossLinkParams { /** Validated post-auth destination to carry over, or null to drop it. */ callbackUrl: string | null isInviteFlow: boolean + /** Marks the visitor as new so the invite page leads with account creation. */ + isNewUser?: boolean } /** @@ -57,11 +90,12 @@ interface AuthCrossLinkParams { */ export function buildAuthCrossLink( path: '/login' | '/signup', - { callbackUrl, isInviteFlow }: AuthCrossLinkParams + { callbackUrl, isInviteFlow, isNewUser = false }: AuthCrossLinkParams ): string { const params = new URLSearchParams() if (isInviteFlow) params.set('invite_flow', 'true') if (callbackUrl) params.set('callbackUrl', callbackUrl) + if (isNewUser) params.set('new', 'true') const query = params.toString() return query ? `${path}?${query}` : path diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index a2349c428eb..380aeb89a42 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -88,11 +88,14 @@ export default function LoginPage({ googleAvailable, microsoftAvailable, isProduction, + registrationDisabled, }: { githubAvailable: boolean googleAvailable: boolean microsoftAvailable: boolean isProduction: boolean + /** DISABLE_REGISTRATION. Hides the signup cross-link, which `/signup` blocks. */ + registrationDisabled: boolean }) { const router = useRouter() const searchParams = useSearchParams() @@ -436,7 +439,7 @@ export default function LoginPage({ )} - {emailEnabled && ( + {emailEnabled && !registrationDisabled && ( )} diff --git a/apps/sim/app/(auth)/login/page.tsx b/apps/sim/app/(auth)/login/page.tsx index ecec5ceb41a..3b0c3f6a96a 100644 --- a/apps/sim/app/(auth)/login/page.tsx +++ b/apps/sim/app/(auth)/login/page.tsx @@ -1,6 +1,8 @@ import { Suspense } from 'react' import type { Metadata } from 'next' +import { isRegistrationDisabled } from '@/lib/core/config/env-flags' import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker' +import LoginLoading from '@/app/(auth)/login/loading' import LoginForm from '@/app/(auth)/login/login-form' export const metadata: Metadata = { @@ -14,12 +16,13 @@ export default async function LoginPage() { await getOAuthProviderStatus() return ( - + }> ) diff --git a/apps/sim/app/(auth)/signup/page.tsx b/apps/sim/app/(auth)/signup/page.tsx index 3d5a8933cd6..d43dc7c0475 100644 --- a/apps/sim/app/(auth)/signup/page.tsx +++ b/apps/sim/app/(auth)/signup/page.tsx @@ -1,7 +1,12 @@ import type { Metadata } from 'next' +import type { SearchParams } from 'nuqs/server' import { isEmailSignupDisabled, isRegistrationDisabled } from '@/lib/core/config/env-flags' +import { validateCallbackUrl } from '@/lib/core/security/input-validation' import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification' +import { resolveAuthRedirect } from '@/app/(auth)/auth-redirect' import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker' +import { RegistrationDisabled } from '@/app/(auth)/signup/registration-disabled' +import { signupSearchParamsCache } from '@/app/(auth)/signup/search-params' import SignupForm from '@/app/(auth)/signup/signup-form' export const metadata: Metadata = { @@ -10,9 +15,25 @@ export const metadata: Metadata = { export const dynamic = 'force-dynamic' -export default async function SignupPage() { +export default async function SignupPage({ + searchParams, +}: { + searchParams: Promise +}) { if (isRegistrationDisabled) { - return
Registration is disabled, please contact your admin.
+ const { redirect, callbackUrl, inviteFlow } = await signupSearchParamsCache.parse(searchParams) + const { rawCallbackUrl, isInviteFlow } = resolveAuthRedirect({ + redirect, + callbackUrl, + inviteFlow, + }) + + return ( + + ) } const { githubAvailable, googleAvailable, microsoftAvailable, isProduction } = diff --git a/apps/sim/app/(auth)/signup/registration-disabled.tsx b/apps/sim/app/(auth)/signup/registration-disabled.tsx new file mode 100644 index 00000000000..2096c056506 --- /dev/null +++ b/apps/sim/app/(auth)/signup/registration-disabled.tsx @@ -0,0 +1,31 @@ +import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' +import { AuthHeader, AuthNavPrompt } from '@/app/(auth)/components' + +interface RegistrationDisabledProps { + /** Post-auth destination the visitor arrived with, already validated. */ + callbackUrl: string | null + isInviteFlow: boolean +} + +/** + * The signup page under DISABLE_REGISTRATION. Visitors reach it from a stale + * link, a bookmark, or an invitation, so it wears the same shell as the form it + * replaces and carries the post-auth destination over to login — an invited + * visitor who lands here can still sign in and end up back on their invitation + * rather than losing it. + */ +export function RegistrationDisabled({ callbackUrl, isInviteFlow }: RegistrationDisabledProps) { + return ( +
+ + +
+ ) +} diff --git a/apps/sim/app/(auth)/signup/search-params.ts b/apps/sim/app/(auth)/signup/search-params.ts new file mode 100644 index 00000000000..54c247dc2a9 --- /dev/null +++ b/apps/sim/app/(auth)/signup/search-params.ts @@ -0,0 +1,23 @@ +import { createSearchParamsCache, parseAsString } from 'nuqs/server' + +/** + * The redirect signals the signup page carries. Read once to decide where a + * visitor goes after authenticating, never written, so every parser is nullable + * with no default — absent means "no destination", which is a real state rather + * than something to fall back from. + */ +const signupParsers = { + redirect: parseAsString, + callbackUrl: parseAsString, + inviteFlow: parseAsString, +} as const + +/** `invite_flow` on the wire; camelCase for destructuring. */ +const signupUrlKeys = { urlKeys: { inviteFlow: 'invite_flow' } } as const + +/** + * Server-side reader for the signup page. The client form reads these same keys + * through `useSearchParams` (the read-once auth-signal carve-out), so the wire + * keys here and in `signup-form.tsx` must stay in step. + */ +export const signupSearchParamsCache = createSearchParamsCache(signupParsers, signupUrlKeys) diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 4915da788b3..ad0d5213b97 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -1,6 +1,6 @@ 'use client' -import { Suspense, useEffect, useMemo, useRef, useState } from 'react' +import { Suspense, useEffect, useRef, useState } from 'react' import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' import { createLogger } from '@sim/logger' import { useRouter, useSearchParams } from 'next/navigation' @@ -15,6 +15,7 @@ import { buildAuthCrossLink, DEFAULT_POST_AUTH_ROUTE, POST_AUTH_REDIRECT_STORAGE_KEY, + resolveAuthRedirect, resolvePostSignupDestination, VERIFY_FROM_SIGNUP_ROUTE, } from '@/app/(auth)/auth-redirect' @@ -123,7 +124,11 @@ function SignupFormContent({ const [formError, setFormError] = useState(null) const turnstileRef = useRef(null) const [turnstileSiteKey] = useState(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY')) - const rawRedirectUrl = searchParams.get('redirect') || searchParams.get('callbackUrl') || '' + const { rawCallbackUrl: rawRedirectUrl, isInviteFlow } = resolveAuthRedirect({ + redirect: searchParams.get('redirect'), + callbackUrl: searchParams.get('callbackUrl'), + inviteFlow: searchParams.get('invite_flow'), + }) const isValidRedirectUrl = rawRedirectUrl ? validateCallbackUrl(rawRedirectUrl) : false const invalidCallbackRef = useRef(false) if (rawRedirectUrl && !isValidRedirectUrl && !invalidCallbackRef.current) { @@ -131,10 +136,6 @@ function SignupFormContent({ logger.warn('Invalid callback URL detected and blocked:', { url: rawRedirectUrl }) } const redirectUrl = isValidRedirectUrl ? rawRedirectUrl : '' - const isInviteFlow = useMemo( - () => searchParams.get('invite_flow') === 'true' || redirectUrl.startsWith('/invite/'), - [searchParams, redirectUrl] - ) const [name, setName] = useState('') const [nameErrors, setNameErrors] = useState([]) diff --git a/apps/sim/app/(auth)/sso/page.tsx b/apps/sim/app/(auth)/sso/page.tsx index b1e27f22608..d84b8d67834 100644 --- a/apps/sim/app/(auth)/sso/page.tsx +++ b/apps/sim/app/(auth)/sso/page.tsx @@ -1,7 +1,8 @@ import { Suspense } from 'react' import type { Metadata } from 'next' import { redirect } from 'next/navigation' -import { isSsoEnabled } from '@/lib/core/config/env-flags' +import { isRegistrationDisabled, isSsoEnabled } from '@/lib/core/config/env-flags' +import SSOLoading from '@/app/(auth)/sso/loading' import SSOForm from '@/ee/sso/components/sso-form' export const metadata: Metadata = { @@ -16,8 +17,8 @@ export default async function SSOPage() { } return ( - - + }> + ) } diff --git a/apps/sim/app/(auth)/verify/use-verification.ts b/apps/sim/app/(auth)/verify/use-verification.ts index cd88a3d32c6..96bd55f1f88 100644 --- a/apps/sim/app/(auth)/verify/use-verification.ts +++ b/apps/sim/app/(auth)/verify/use-verification.ts @@ -81,7 +81,6 @@ export function useVerification({ const [email, setEmail] = useState('') const [status, setStatus] = useState('idle') const [isResending, setIsResending] = useState(false) - const [isSendingInitialOtp, setIsSendingInitialOtp] = useState(false) const [errorMessage, setErrorMessage] = useState('') useEffect(() => { @@ -89,12 +88,6 @@ export function useVerification({ if (storedEmail) setEmail(storedEmail) }, []) - useEffect(() => { - if (email && !isSendingInitialOtp && hasEmailService) { - setIsSendingInitialOtp(true) - } - }, [email, isSendingInitialOtp, hasEmailService]) - const isOtpComplete = otp.length === 6 async function verifyCode() { diff --git a/apps/sim/app/(auth)/verify/verify-content.tsx b/apps/sim/app/(auth)/verify/verify-content.tsx index 4fc9009a2f5..88d1c2e9d67 100644 --- a/apps/sim/app/(auth)/verify/verify-content.tsx +++ b/apps/sim/app/(auth)/verify/verify-content.tsx @@ -46,21 +46,15 @@ function VerificationForm({ const isInvalidOtp = status === 'error' const [countdown, setCountdown] = useState(0) - const [isResendDisabled, setIsResendDisabled] = useState(false) useEffect(() => { - if (countdown > 0) { - const timer = setTimeout(() => setCountdown((c) => c - 1), 1000) - return () => clearTimeout(timer) - } - if (countdown === 0 && isResendDisabled) { - setIsResendDisabled(false) - } - }, [countdown, isResendDisabled]) + if (countdown <= 0) return + const timer = setTimeout(() => setCountdown((c) => c - 1), 1000) + return () => clearTimeout(timer) + }, [countdown]) const handleResend = () => { resendCode() - setIsResendDisabled(true) setCountdown(30) } @@ -128,7 +122,7 @@ function VerificationForm({ Resend in {countdown}s ) : ( - + Resend )} diff --git a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx index a6d0d89cbf5..5576c035050 100644 --- a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx +++ b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx @@ -185,9 +185,9 @@ export default function ResumeExecutionPage({ executionId, selectedContextId ?? undefined ) - const [selectedStatus, setSelectedStatus] = - useState('paused') - const [queuePosition, setQueuePosition] = useState(undefined) + const selectedStatus: PausePointWithQueue['resumeStatus'] = + selectedDetail?.pausePoint.resumeStatus ?? 'paused' + const queuePosition = selectedDetail?.pausePoint.queuePosition const resumeInputsRef = useRef>({}) const [resumeInput, setResumeInput] = useState('') const [formValuesByContext, setFormValuesByContext] = useState< @@ -440,10 +440,7 @@ export default function ResumeExecutionPage({ [] ) - const selectedOperation = useMemo( - () => selectedDetail?.pausePoint.response?.data?.operation || 'human', - [selectedDetail] - ) + const selectedOperation = selectedDetail?.pausePoint.response?.data?.operation || 'human' const isHumanMode = selectedOperation === 'human' const inputFormatFields = useMemo( @@ -524,8 +521,6 @@ export default function ResumeExecutionPage({ useEffect(() => { if (!selectedDetail) return - setSelectedStatus(selectedDetail.pausePoint.resumeStatus) - setQueuePosition(selectedDetail.pausePoint.queuePosition) seedFormFromDetail(selectedDetail) }, [selectedDetail, seedFormFromDetail]) @@ -604,7 +599,6 @@ export default function ResumeExecutionPage({ }) if (!ok) { setError(payload.error || 'Failed to resume execution.') - setSelectedStatus(selectedDetail.pausePoint.resumeStatus) return } const nextStatus = payload.status === 'queued' ? 'queued' : 'resuming' @@ -641,8 +635,6 @@ export default function ResumeExecutionPage({ } } ) - setSelectedStatus(nextStatus) - setQueuePosition(nextQueuePosition) setSelectedContextId((prev) => (prev !== selectedContextId ? prev : fallbackContextId)) setMessage( payload.status === 'queued' ? 'Resume request queued.' : 'Resume started successfully.' diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts index 1c60db08fa3..e49b485cca3 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.test.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.test.ts @@ -89,6 +89,7 @@ vi.mock('@/lib/messaging/email/mailer', () => ({ })) vi.mock('@/components/emails', () => ({ + getOtpSubject: (label: string) => `Verification code for ${label}`, renderOTPEmail: mockRenderOTPEmail, })) diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.ts b/apps/sim/app/api/chat/[identifier]/otp/route.ts index 9f7fffd741f..aa936877cc5 100644 --- a/apps/sim/app/api/chat/[identifier]/otp/route.ts +++ b/apps/sim/app/api/chat/[identifier]/otp/route.ts @@ -3,7 +3,7 @@ import { chat } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { renderOTPEmail } from '@/components/emails' +import { getOtpSubject, renderOTPEmail } from '@/components/emails' import { requestChatEmailOtpContract, verifyChatEmailOtpContract } from '@/lib/api/contracts/chats' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { RateLimiter } from '@/lib/core/rate-limiter' @@ -120,7 +120,7 @@ export const POST = withRouteHandler( const emailResult = await sendEmail({ to: email, - subject: `Verification code for ${deployment.title || 'Chat'}`, + subject: getOtpSubject(deployment.title || 'Chat'), html: emailHtml, }) diff --git a/apps/sim/app/api/contact/route.ts b/apps/sim/app/api/contact/route.ts index 2b610ec2114..5d1c0404eba 100644 --- a/apps/sim/app/api/contact/route.ts +++ b/apps/sim/app/api/contact/route.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { renderHelpConfirmationEmail } from '@/components/emails' +import { getRequestConfirmationSubject, renderHelpConfirmationEmail } from '@/components/emails' import { getContactTopicLabel, mapContactTopicToHelpType, @@ -168,7 +168,7 @@ ${message} await sendEmail({ to: [email], - subject: `We've received your message: ${subject}`, + subject: getRequestConfirmationSubject(subject), html: confirmationHtml, from: getFromEmailAddress(), replyTo: `help@${helpInboxDomain}`, diff --git a/apps/sim/app/api/files/public/[token]/otp/route.test.ts b/apps/sim/app/api/files/public/[token]/otp/route.test.ts index eb363eb7d5f..bce515c9237 100644 --- a/apps/sim/app/api/files/public/[token]/otp/route.test.ts +++ b/apps/sim/app/api/files/public/[token]/otp/route.test.ts @@ -50,7 +50,10 @@ vi.mock('@/lib/core/security/otp', () => ({ OTP_IP_RATE_LIMIT: { maxTokens: 10, refillRate: 10, refillIntervalMs: 1000 }, OTP_EMAIL_RATE_LIMIT: { maxTokens: 3, refillRate: 3, refillIntervalMs: 1000 }, })) -vi.mock('@/components/emails', () => ({ renderOTPEmail: mockRenderOTPEmail })) +vi.mock('@/components/emails', () => ({ + getOtpSubject: (label: string) => `Verification code for ${label}`, + renderOTPEmail: mockRenderOTPEmail, +})) vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: mockSendEmail })) vi.mock('@/lib/core/rate-limiter', () => ({ RateLimiter: class { diff --git a/apps/sim/app/api/files/public/[token]/otp/route.ts b/apps/sim/app/api/files/public/[token]/otp/route.ts index 0dd240788fd..c6b556ad41d 100644 --- a/apps/sim/app/api/files/public/[token]/otp/route.ts +++ b/apps/sim/app/api/files/public/[token]/otp/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { normalizeEmail } from '@sim/utils/string' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' -import { renderOTPEmail } from '@/components/emails' +import { getOtpSubject, renderOTPEmail } from '@/components/emails' import { requestPublicFileOtpContract, verifyPublicFileOtpContract, @@ -104,7 +104,7 @@ export const POST = withRouteHandler( const emailHtml = await renderOTPEmail(otp, email, 'email-verification', SHARE_EMAIL_LABEL) const emailResult = await sendEmail({ to: email, - subject: `Verification code for ${SHARE_EMAIL_LABEL}`, + subject: getOtpSubject(SHARE_EMAIL_LABEL), html: emailHtml, }) if (!emailResult.success) { diff --git a/apps/sim/app/api/help/route.ts b/apps/sim/app/api/help/route.ts index 3c10f68f4d4..e87f4a38578 100644 --- a/apps/sim/app/api/help/route.ts +++ b/apps/sim/app/api/help/route.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' -import { renderHelpConfirmationEmail } from '@/components/emails' +import { getRequestConfirmationSubject, renderHelpConfirmationEmail } from '@/components/emails' import { helpFormBodySchema } from '@/lib/api/contracts/common' import { validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' @@ -130,7 +130,7 @@ ${message} await sendEmail({ to: [email], - subject: `Your ${type} request has been received: ${subject}`, + subject: getRequestConfirmationSubject(subject, type), html: confirmationHtml, from: getFromEmailAddress(), replyTo: getHelpEmailAddress(), diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index 5f185a5d6ec..211edf1305b 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -73,6 +73,7 @@ import { import { getMeaningfulWorkflowDescription } from '@/lib/mcp/workflow-tool-schema' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' +import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const logger = createLogger('WorkflowMcpServeAPI') @@ -293,7 +294,13 @@ async function projectWorkflowMcpModelContent( throw new Error('MCP workflow execution provenance is invalid') } const projection = projectResolvedSecretModelContent(value, registry) - if (!projection.safe) throw new Error('MCP workflow output could not be safely projected') + if (!projection.safe) { + refuseResolvedSecretProjection({ + site: 'mcpServe.workflowOutput', + message: 'MCP workflow output could not be safely projected', + registry, + }) + } return projection.value } @@ -939,7 +946,10 @@ async function handleToolsCall( }) : rawErrorMessage if (typeof errorMessage !== 'string') { - throw new Error('MCP workflow execution error could not be safely projected') + refuseResolvedSecretProjection({ + site: 'mcpServe.executionError', + message: 'MCP workflow execution error could not be safely projected', + }) } const status = getWorkflowErrorStatus(response.status) const responseHeaders: Record = {} diff --git a/apps/sim/app/cli/auth/page.test.tsx b/apps/sim/app/cli/auth/page.test.tsx new file mode 100644 index 00000000000..ce1cbcd7d86 --- /dev/null +++ b/apps/sim/app/cli/auth/page.test.tsx @@ -0,0 +1,71 @@ +/** + * @vitest-environment node + */ +import { envFlagsMock } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockRedirect } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockRedirect: vi.fn((url: string) => { + throw new Error(`NEXT_REDIRECT:${url}`) + }), +})) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) + +vi.mock('next/navigation', () => ({ + redirect: mockRedirect, +})) + +import CliAuthPage from '@/app/cli/auth/page' + +/** BASE64URL, 43 chars; pairing is `XXXX-XXXX` over the no-look-alike alphabet. */ +const REQUEST = 'r'.repeat(43) +const CHALLENGE = 'c'.repeat(43) +const PAIRING = 'ABCD-2345' + +const EXPECTED_CALLBACK = encodeURIComponent( + `/cli/auth?request=${REQUEST}&challenge=${CHALLENGE}&pairing=${PAIRING}` +) + +function pageProps() { + return { + searchParams: Promise.resolve({ + request: REQUEST, + challenge: CHALLENGE, + pairing: PAIRING, + }), + } +} + +describe('CliAuthPage signed-out bounce', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue(null) + }) + + afterEach(() => { + envFlagsMock.isRegistrationDisabled = false + }) + + it('sends a signed-out visitor to signup, carrying the handoff as callbackUrl', async () => { + await expect(CliAuthPage(pageProps())).rejects.toThrow( + `NEXT_REDIRECT:/signup?callbackUrl=${EXPECTED_CALLBACK}` + ) + }) + + /** + * Nobody can create an account under the flag, so the pairing visitor is + * necessarily an existing user and signup would be a guaranteed dead end. + */ + it('sends them to login instead when registration is disabled', async () => { + envFlagsMock.isRegistrationDisabled = true + + await expect(CliAuthPage(pageProps())).rejects.toThrow( + `NEXT_REDIRECT:/login?callbackUrl=${EXPECTED_CALLBACK}` + ) + }) +}) diff --git a/apps/sim/app/cli/auth/page.tsx b/apps/sim/app/cli/auth/page.tsx index 2bc0d86370a..e49a47be4cc 100644 --- a/apps/sim/app/cli/auth/page.tsx +++ b/apps/sim/app/cli/auth/page.tsx @@ -3,6 +3,8 @@ import type { Metadata } from 'next' import { redirect } from 'next/navigation' import type { SearchParams } from 'nuqs/server' import { getSession } from '@/lib/auth' +import { isRegistrationDisabled } from '@/lib/core/config/env-flags' +import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' import { AuthShell } from '@/app/(auth)/components' import { resolveCliAuthRequest } from '@/app/cli/auth/cli-auth-request' import { CliAuthView } from '@/app/cli/auth/cli-auth-view' @@ -30,6 +32,10 @@ export const dynamic = 'force-dynamic' * configuring Sim for the first time has no account yet. Both auth pages * cross-link carrying the same `callbackUrl`, so a returning user is one click * from login with their destination intact. + * + * That reasoning inverts under DISABLE_REGISTRATION: nobody can create an + * account, so the pairing visitor is necessarily an existing user and signup is + * guaranteed to be the wrong hop. Go straight to login there. */ export default async function CliAuthPage({ searchParams, @@ -49,7 +55,12 @@ export default async function CliAuthPage({ challenge: resolution.request.challenge, pairing: resolution.request.pairing, }) - redirect(`/signup?callbackUrl=${encodeURIComponent(`/cli/auth?${query}`)}`) + redirect( + buildAuthCrossLink(isRegistrationDisabled ? '/login' : '/signup', { + callbackUrl: `/cli/auth?${query}`, + isInviteFlow: false, + }) + ) } return ( diff --git a/apps/sim/app/invite/[id]/invite.test.tsx b/apps/sim/app/invite/[id]/invite.test.tsx index 83fba300438..24363971dc4 100644 --- a/apps/sim/app/invite/[id]/invite.test.tsx +++ b/apps/sim/app/invite/[id]/invite.test.tsx @@ -27,9 +27,7 @@ const { }, mockPush: vi.fn(), mockRequestJson: vi.fn(), - mockSearchParams: { - get: (key: string) => (key === 'token' ? 'token-1' : null), - }, + mockSearchParams: { current: new URLSearchParams('token=token-1') }, mockSetActive: vi.fn(), mockSetQueryData: vi.fn(), mockSignOut: vi.fn(), @@ -43,7 +41,7 @@ vi.mock('@sim/logger', () => ({ vi.mock('next/navigation', () => ({ useParams: () => ({ id: 'invitation-1' }), useRouter: () => ({ push: mockPush }), - useSearchParams: () => mockSearchParams, + useSearchParams: () => mockSearchParams.current, })) vi.mock('@tanstack/react-query', async () => { @@ -106,15 +104,18 @@ vi.mock('@/app/invite/components', () => ({ InviteLayout: ({ children }: { children: ReactNode }) => children, InviteStatusCard: ({ actions = [], + description, title, type, }: { actions?: Array<{ label: string; onClick: () => void }> + description?: ReactNode title: string type: string }) => ( <>
{title}
+
{description}
{actions.map((action) => ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts index 0c97956e2ce..80865a985e3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types.ts @@ -14,24 +14,11 @@ export interface ContextMenuPosition { y: number } -/** - * Sort field options for terminal entries - */ -export type SortField = 'timestamp' - /** * Sort direction options */ export type SortDirection = 'asc' | 'desc' -/** - * Sort configuration for terminal entries - */ -export interface SortConfig { - field: SortField - direction: SortDirection -} - /** * Status type for console entries */ diff --git a/apps/sim/components/emails/_styles/base.tokens.test.ts b/apps/sim/components/emails/_styles/base.tokens.test.ts index c1223ebec42..0baac25f776 100644 --- a/apps/sim/components/emails/_styles/base.tokens.test.ts +++ b/apps/sim/components/emails/_styles/base.tokens.test.ts @@ -1,9 +1,7 @@ /** * Email styles cannot use CSS variables — clients strip them — so `base.ts` - * hardcodes hex copies of the platform tokens. Nothing else detects it when - * `globals.css`, `tailwind.config.ts`, or the chip chrome moves and the copies - * go stale, which is exactly how they drifted before. This suite is that - * detector. + * hardcodes hex copies of the platform tokens. This suite fails when those + * copies drift from `globals.css`, `tailwind.config.ts`, or the chip chrome. * * @vitest-environment node */ @@ -11,32 +9,28 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { baseStyles, colors, typography } from '@/components/emails/_styles' +import tailwindConfig from '@/tailwind.config' const APP_ROOT = join(__dirname, '../../..') const globalsCss = readFileSync(join(APP_ROOT, 'app/_styles/globals.css'), 'utf8') -const tailwindConfig = readFileSync(join(APP_ROOT, 'tailwind.config.ts'), 'utf8') const chipChrome = readFileSync( join(APP_ROOT, '../../packages/emcn/src/components/chip/chip-chrome.ts'), 'utf8' ) +const tailwindFontSize = tailwindConfig.theme?.extend?.fontSize as Record + /** - * The light-mode `:root` block. Dark mode redefines the same names later in the - * file, and emails are light-only, so the FIRST definition is the one to read. + * Dark mode redefines the same names later in the file and emails are + * light-only, so the FIRST definition is the one to read. */ function readCssVar(name: string): string { - const match = globalsCss.match(new RegExp(`--${name}:\\s*([^;]+);`)) + const match = globalsCss.match(new RegExp(`(?:^|[^-\\w])--${name}:\\s*([^;]+);`, 'm')) if (!match) throw new Error(`--${name} not found in globals.css`) return match[1].trim() } -function readTailwindFontSize(name: string): string { - const match = tailwindConfig.match(new RegExp(`\\b${name}:\\s*'([^']+)'`)) - if (!match) throw new Error(`fontSize.${name} not found in tailwind.config.ts`) - return match[1] -} - /** Every email color token and the platform variable it copies. */ const COLOR_MIRROR: Record = { bgOuter: 'surface-1', @@ -44,6 +38,7 @@ const COLOR_MIRROR: Record = { surfaceSubtle: 'surface-3', textPrimary: 'text-primary', textBody: 'text-body', + textSecondary: 'text-secondary', textMuted: 'text-muted', textInverse: 'text-inverse', border: 'border', @@ -52,10 +47,7 @@ const COLOR_MIRROR: Record = { footerBg: 'surface-1', } -/** - * Tokens with no single CSS variable behind them. Each needs a stated reason — - * an entry here is a deliberate exception, not an oversight. - */ +/** Tokens with no single CSS variable behind them, and why. */ const UNMIRRORED_COLORS: Record = { brandTertiary: 'Runtime-conditional on getBrandConfig(); neutral default equals --text-primary.', } @@ -69,30 +61,25 @@ describe('email color tokens mirror globals.css', () => { it('every color token is either mirrored or has a written exemption', () => { const accounted = new Set([...Object.keys(COLOR_MIRROR), ...Object.keys(UNMIRRORED_COLORS)]) - const unaccounted = Object.keys(colors).filter((key) => !accounted.has(key)) - expect(unaccounted).toEqual([]) - }) - - it('exemptions state a reason', () => { - for (const reason of Object.values(UNMIRRORED_COLORS)) { - expect(reason.trim().length).toBeGreaterThan(0) - } + expect(Object.keys(colors).filter((key) => !accounted.has(key))).toEqual([]) }) }) describe('email type scale mirrors tailwind.config.ts', () => { - it.each(['caption', 'base', 'md'])('fontSize.%s matches the Tailwind token', (name) => { - expect(typography.fontSize[name as 'caption' | 'base' | 'md']).toBe(readTailwindFontSize(name)) + it.each(['caption', 'small', 'base', 'md'])('fontSize.%s matches the Tailwind token', (name) => { + expect(typography.fontSize[name as keyof typeof typography.fontSize]).toBe( + tailwindFontSize[name] + ) }) it('sm is Tailwind stock 14px — the size text-sm resolves to in chip chrome', () => { expect(typography.fontSize.sm).toBe('14px') + expect(tailwindFontSize.sm).toBeUndefined() expect(chipChrome).toContain('text-sm') }) - it('display is deliberately off-scale (no platform headline-numeral token)', () => { - expect(typography.fontSize.display).toBe('24px') - expect(tailwindConfig).not.toContain("'24px'") + it('display is deliberately off-scale — the platform has no headline numeral', () => { + expect(Object.values(tailwindFontSize)).not.toContain(typography.fontSize.display) }) }) @@ -106,10 +93,9 @@ describe('email geometry mirrors the platform', () => { it('the CTA transcribes chipGeometryClass', () => { const geometry = chipChrome.match(/chipGeometryClass = `([^`]+)`/)?.[1] expect(geometry).toBeDefined() - expect(geometry).toContain('h-[30px]') - expect(geometry).toContain('rounded-lg') - expect(geometry).toContain('px-2') - expect(geometry).toContain('text-sm') + for (const token of ['h-[30px]', 'rounded-lg', 'px-2', 'text-sm']) { + expect(geometry).toContain(token) + } expect(baseStyles.button.lineHeight).toBe('30px') expect(baseStyles.button.borderRadius).toBe('8px') diff --git a/apps/sim/components/emails/_styles/base.ts b/apps/sim/components/emails/_styles/base.ts index c9b51e90de4..6aa4c7d02d0 100644 --- a/apps/sim/components/emails/_styles/base.ts +++ b/apps/sim/components/emails/_styles/base.ts @@ -21,6 +21,8 @@ function buildColors() { textPrimary: '#1a1a1a', /** Body and value text — platform `--text-body` */ textBody: '#434343', + /** De-emphasized text inside a body block — platform `--text-secondary` */ + textSecondary: '#525252', /** Muted text (labels, footer) — platform `--text-muted` */ textMuted: '#7a7a7a', /** Accent for buttons and links — neutral by default, brand color when whitelabeled */ @@ -56,10 +58,13 @@ export const typography = { fontFamily: "'Season Sans', system-ui, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif", /** - * Deliberately brand-free, for the plain personal emails — those read as a - * message typed by a person, so they must NOT carry the brand face. + * Deliberately brand-free, for emails that must read as typed by a person + * (the plain founder notes, the agent's thread replies). Carries the same + * non-brand fallbacks as {@link fontFamily} so Android and Linux clients land + * on Roboto rather than a generic sans. */ - systemFontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + systemFontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', /** * `caption`/`base`/`md` are Sim's own scale from `tailwind.config.ts`. `sm` is * Tailwind's stock 14px — not a Sim token, but what `text-sm` resolves to in @@ -67,6 +72,7 @@ export const typography = { */ fontSize: { caption: '12px', + small: '13px', sm: '14px', base: '15px', /** Email body copy. Larger than the app's 15px `base` — the client default. */ @@ -104,7 +110,7 @@ export const spacing = { paragraphGap: 12, } -/** Shared body-copy ramp. {@link baseStyles.paragraph} and `greeting` differ only in margin. */ +/** Shared body-copy ramp. */ const bodyText = { fontSize: typography.fontSize.md, lineHeight: typography.lineHeight.body, @@ -113,7 +119,7 @@ const bodyText = { fontFamily: typography.fontFamily, } -/** Shared box geometry. {@link baseStyles.infoBox} and `errorBox` differ only in fill. */ +/** Shared box geometry. */ const boxGeometry = { padding: '16px 18px', borderRadius: RADIUS, @@ -218,10 +224,9 @@ export const baseStyles = { }, /** - * The closing fine-print line inside the card (who this was sent to, when it - * fires again). Same ramp as {@link footerText}, but left-aligned — the card - * is left-aligned while the footer's cells are not. Every template spelled - * this out as a spread override; use the token. + * The closing fine-print line inside the card. Same ramp as + * {@link footerText}, but left-aligned — the card is left-aligned while the + * footer's own cells are not. */ footnote: { fontSize: typography.fontSize.caption, diff --git a/apps/sim/components/emails/agent/inbox-response-email.tsx b/apps/sim/components/emails/agent/inbox-response-email.tsx new file mode 100644 index 00000000000..05322657c83 --- /dev/null +++ b/apps/sim/components/emails/agent/inbox-response-email.tsx @@ -0,0 +1,184 @@ +import { type ComponentType, type CSSProperties, createElement, type ReactNode } from 'react' +import { Body, Head, Html, Link, Markdown, Section, Text } from '@react-email/components' +import { colors, fontWeight, typography } from '@/components/emails/_styles' +import { getBrandConfig } from '@/ee/whitelabeling' + +const CODE_FONT_FAMILY = "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace" + +const BODY_TEXT = { + fontSize: typography.fontSize.base, + lineHeight: '25px', + color: colors.textPrimary, + fontFamily: typography.systemFontFamily, + fontWeight: fontWeight.normal, +} + +const HEADING = { + fontWeight: fontWeight.semibold, + color: colors.textPrimary, + margin: '24px 0 12px 0', + fontFamily: typography.systemFontFamily, +} + +const CODE_SURFACE = { + backgroundColor: colors.surfaceSubtle, + fontFamily: CODE_FONT_FAMILY, + fontSize: typography.fontSize.small, + color: colors.textPrimary, +} + +const emailStyles = { + body: BODY_TEXT, + content: { margin: 0 }, + markdownContainer: { margin: 0 }, + signature: { color: colors.textSecondary, marginTop: '32px', fontSize: typography.fontSize.sm }, + signatureText: { + color: colors.textSecondary, + margin: '0 0 16px 0', + fontSize: typography.fontSize.sm, + lineHeight: '25px', + fontFamily: typography.systemFontFamily, + }, + signatureLink: { + color: colors.textPrimary, + textDecoration: 'underline', + textDecorationStyle: 'dashed', + textUnderlineOffset: '2px', + }, +} satisfies Record + +const markdownStyles = { + p: { ...BODY_TEXT, margin: '0 0 16px 0' }, + h1: { ...HEADING, fontSize: typography.fontSize.display, lineHeight: '32px' }, + h2: { ...HEADING, fontSize: '20px', lineHeight: '28px' }, + h3: { ...HEADING, fontSize: typography.fontSize.md, lineHeight: '24px' }, + h4: { ...HEADING, fontSize: typography.fontSize.base, lineHeight: '25px' }, + /** + * `bold`, not `strong` — that is the key `@react-email/markdown` looks up. A + * `strong` key silently falls through to its default of 700, off the + * platform's 400/500/600 scale. + */ + bold: { fontWeight: fontWeight.semibold, color: colors.textPrimary }, + codeInline: { ...CODE_SURFACE, padding: '2px 6px', borderRadius: '4px' }, + codeBlock: { + ...CODE_SURFACE, + padding: '16px', + borderRadius: '8px', + border: `1px solid ${colors.border}`, + overflowX: 'auto', + margin: '24px 0', + lineHeight: '21px', + }, + table: { borderCollapse: 'collapse', margin: '16px 0' }, + th: { + border: `1px solid ${colors.border}`, + padding: '8px 12px', + textAlign: 'left', + fontSize: typography.fontSize.sm, + backgroundColor: colors.surfaceSubtle, + fontWeight: fontWeight.semibold, + }, + td: { + border: `1px solid ${colors.border}`, + padding: '8px 12px', + textAlign: 'left', + fontSize: typography.fontSize.sm, + }, + blockQuote: { + borderLeft: `4px solid ${colors.border}`, + margin: '16px 0', + padding: '4px 16px', + color: colors.textSecondary, + fontStyle: 'italic', + }, + a: { + color: colors.textPrimary, + textDecoration: 'underline', + textDecorationStyle: 'dashed', + textUnderlineOffset: '2px', + }, + ul: { margin: '16px 0', paddingLeft: '24px' }, + ol: { margin: '16px 0', paddingLeft: '24px' }, + li: { margin: '4px 0' }, + hr: { border: 'none', borderTop: `1px solid ${colors.border}`, margin: '24px 0' }, +} satisfies Record + +interface EmailMarkdownProps { + children?: string + markdownContainerStyles?: CSSProperties + markdownCustomStyles?: Record +} + +const EmailMarkdown = Markdown as ComponentType + +/** + * Shell for the agent's reply. Deliberately not {@link EmailLayout}: this lands + * inside an existing mail thread, where a logo header and unsubscribe footer + * would be wrong — the same carve-out as `plainEmailStyles`. + */ +function InboxShell({ + children, + chatUrl, + linkLabel, +}: { + children?: ReactNode + chatUrl: string + linkLabel: string +}) { + return createElement( + Html, + { lang: 'en', dir: 'ltr' }, + createElement(Head), + createElement( + Body, + { style: emailStyles.body }, + createElement(Section, { style: emailStyles.content }, children), + createElement( + Section, + { style: emailStyles.signature }, + createElement( + Text, + { style: emailStyles.signatureText }, + createElement(Link, { href: chatUrl, style: emailStyles.signatureLink }, linkLabel) + ), + createElement( + Text, + { style: emailStyles.signatureText }, + 'Best,', + createElement('br'), + getBrandConfig().name + ) + ) + ) + ) +} + +/** The agent's reply carrying its markdown answer. */ +export function InboxResponseEmail({ markdown, chatUrl }: { markdown: string; chatUrl: string }) { + return createElement( + InboxShell, + { chatUrl, linkLabel: 'View full conversation' }, + createElement( + EmailMarkdown, + { + markdownContainerStyles: emailStyles.markdownContainer, + markdownCustomStyles: markdownStyles, + }, + markdown + ) + ) +} + +/** The agent's reply when it could not complete the task. */ +export function InboxErrorEmail({ error, chatUrl }: { error: string; chatUrl: string }) { + return createElement( + InboxShell, + { chatUrl, linkLabel: 'View details' }, + createElement(Text, { style: markdownStyles.p }, "I wasn't able to complete this task."), + createElement( + Text, + { style: { ...markdownStyles.p, color: colors.textSecondary } }, + `Error: ${error}` + ) + ) +} diff --git a/apps/sim/components/emails/billing/payment-failed-email.tsx b/apps/sim/components/emails/billing/payment-failed-email.tsx index ace471b9747..e531ae4c376 100644 --- a/apps/sim/components/emails/billing/payment-failed-email.tsx +++ b/apps/sim/components/emails/billing/payment-failed-email.tsx @@ -1,6 +1,7 @@ import { Link, Section, Text } from '@react-email/components' import { baseStyles, colors, fontWeight } from '@/components/emails/_styles' import { EmailButton, EmailLayout } from '@/components/emails/components' +import { getEmailSubject } from '@/components/emails/subjects' import { getBrandConfig } from '@/ee/whitelabeling' interface PaymentFailedEmailProps { @@ -9,7 +10,6 @@ interface PaymentFailedEmailProps { lastFourDigits?: string billingPortalUrl: string failureReason?: string - sentDate?: Date } export function PaymentFailedEmail({ @@ -18,11 +18,10 @@ export function PaymentFailedEmail({ lastFourDigits, billingPortalUrl, failureReason, - sentDate = new Date(), }: PaymentFailedEmailProps) { const brand = getBrandConfig() - const previewText = `${brand.name}: Payment Failed - Action Required` + const previewText = getEmailSubject('payment-failed') return ( diff --git a/apps/sim/components/emails/boundary.test.ts b/apps/sim/components/emails/boundary.test.ts new file mode 100644 index 00000000000..cfe6956c8d6 --- /dev/null +++ b/apps/sim/components/emails/boundary.test.ts @@ -0,0 +1,98 @@ +/** + * Enforces that every email leaves the app through the shared layer: + * `render.ts` for the body, `subjects.ts` for the subject. + * + * @vitest-environment node + */ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { renderInboxResponseEmail } from '@/components/emails/render' + +const APP_ROOT = join(__dirname, '../..') +const EMAILS_DIR = join(__dirname) + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + if (entry === 'node_modules' || entry === '.next') continue + const full = join(dir, entry) + if (statSync(full).isDirectory()) walk(full, out) + else if (/\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry)) out.push(full) + } + return out +} + +/** + * Only files that actually send mail. Scanning every module under `lib` would + * flag unrelated `subject:` keys (mail-tool params, schema fixtures). + * `app/api/tools/ses` is excluded because it sends the end user's own mail + * through their credentials — it is not a Sim-branded email. + */ +const senderFiles = ['lib', 'app/api', 'background'] + .flatMap((root) => walk(join(APP_ROOT, root))) + .filter((f) => !f.includes(join(APP_ROOT, 'app/api/tools/ses'))) + .filter((f) => readFileSync(f, 'utf8').includes('sendEmail')) + +const rel = (f: string) => f.slice(APP_ROOT.length + 1) + +/** Template components, read from `render.ts`'s imports so new ones are covered. */ +const renderSource = readFileSync(join(EMAILS_DIR, 'render.ts'), 'utf8') +const templateComponents = [ + ...new Set( + [...renderSource.slice(0, renderSource.indexOf('export ')).matchAll(/\b(\w*Email)\b/g)].map( + (m) => m[1] + ) + ), +] + +describe('every email goes through the shared layer', () => { + it('finds the senders and the templates', () => { + expect(senderFiles.length).toBeGreaterThan(5) + expect(templateComponents).toContain('WelcomeEmail') + }) + + it('no sender reaches for @react-email/render', () => { + // Substring, not an import-statement match — senders here use `await import()` too. + const offenders = senderFiles.filter((f) => + readFileSync(f, 'utf8').includes('@react-email/render') + ) + expect(offenders.map(rel)).toEqual([]) + }) + + it('no sender names a template component instead of its render wrapper', () => { + const offenders: string[] = [] + for (const file of senderFiles) { + const src = readFileSync(file, 'utf8') + for (const component of templateComponents) { + // Whole-file scan so static and dynamic imports are both covered. The + // word boundary keeps `PaymentFailedEmail` from matching inside + // `renderPaymentFailedEmail`. + if (new RegExp(`\\b${component}\\b`).test(src)) { + offenders.push(`${rel(file)} -> ${component}`) + } + } + } + expect(offenders).toEqual([]) + }) + + it('no sender builds its own subject line', () => { + const offenders: string[] = [] + for (const file of senderFiles) { + for (const match of readFileSync(file, 'utf8').matchAll(/subject:\s*(['"`])(.*?)\1/g)) { + // Bracket-tagged subjects are internal team-inbox alerts, not product email. + if (match[2].startsWith('[')) continue + offenders.push(`${rel(file)}: ${match[2]}`) + } + } + expect(offenders).toEqual([]) + }) + + it('the agent reply keeps markdown emphasis on the platform weight scale', async () => { + const html = await renderInboxResponseEmail({ + markdown: 'Some **emphasis** here.', + chatUrl: 'https://example.test/chat', + }) + expect(html).not.toMatch(/font-weight:(700|bold)/) + expect(html).toContain('font-weight:600') + }) +}) diff --git a/apps/sim/components/emails/render.ts b/apps/sim/components/emails/render.ts index 3b8f7e694f2..83d285fe17f 100644 --- a/apps/sim/components/emails/render.ts +++ b/apps/sim/components/emails/render.ts @@ -1,4 +1,5 @@ import { render } from '@react-email/render' +import { InboxErrorEmail, InboxResponseEmail } from '@/components/emails/agent/inbox-response-email' import { ExistingAccountEmail, OnboardingFollowupEmail, @@ -30,8 +31,6 @@ import type { UpgradeReason } from '@/lib/billing/upgrade-reasons' import { getBaseUrl } from '@/lib/core/utils/urls' import type { ScheduleDisableReason } from '@/lib/workflows/schedules/disable-reasons' -export { getEmailSubject, getLimitEmailSubject } from './subjects' - interface WorkspaceInvitation { workspaceId: string workspaceName: string @@ -284,3 +283,24 @@ export async function renderPaymentFailedEmail(params: { }) ) } + +/** Neutralize `javascript:`/`data:` hrefs that agent-authored markdown could emit. */ +function stripUnsafeUrls(html: string): string { + return html.replace(/href\s*=\s*(['"])(?:javascript|vbscript|data):.*?\1/gi, 'href="#"') +} + +/** The agent's reply to an inbound email. */ +export async function renderInboxResponseEmail(params: { + markdown: string + chatUrl: string +}): Promise { + return stripUnsafeUrls(await render(InboxResponseEmail(params))) +} + +/** The agent's reply when the task could not be completed. */ +export async function renderInboxErrorEmail(params: { + error: string + chatUrl: string +}): Promise { + return stripUnsafeUrls(await render(InboxErrorEmail(params))) +} diff --git a/apps/sim/components/emails/subjects.ts b/apps/sim/components/emails/subjects.ts index b5a4dc7ac71..b8df1a5f27f 100644 --- a/apps/sim/components/emails/subjects.ts +++ b/apps/sim/components/emails/subjects.ts @@ -12,12 +12,10 @@ export type EmailSubjectType = | 'invitation' | 'batch-invitation' | 'workspace-added' - | 'help-confirmation' | 'enterprise-subscription' | 'usage-threshold' | 'free-tier-upgrade' - | 'plan-welcome-pro' - | 'plan-welcome-team' + | 'payment-failed' | 'credit-purchase' | 'abandoned-checkout' | 'free-tier-exhausted' @@ -52,18 +50,14 @@ export function getEmailSubject(type: EmailSubjectType): string { return `You've been invited to join a team and workspaces on ${brandName}` case 'workspace-added': return `You've been added to a workspace on ${brandName}` - case 'help-confirmation': - return 'Your request has been received' case 'enterprise-subscription': return `Your Enterprise Plan is now active on ${brandName}` case 'usage-threshold': return `You're nearing your monthly budget on ${brandName}` case 'free-tier-upgrade': return `You're at 80% of your free credits on ${brandName}` - case 'plan-welcome-pro': - return `Your Pro plan is now active on ${brandName}` - case 'plan-welcome-team': - return `Your Team plan is now active on ${brandName}` + case 'payment-failed': + return `Payment failed on ${brandName} — action required` case 'credit-purchase': return `Credits added to your ${brandName} account` case 'abandoned-checkout': @@ -92,3 +86,20 @@ export function getLimitEmailSubject(reason: UpgradeReason, kind: 'warning' | 'r const subject = kind === 'reached' ? copy.reachedSubject : copy.warningSubject return `${subject} on ${brandName}` } + +/** The plan's display name is resolved at send time; it carries tier qualifiers. */ +export function getPlanWelcomeSubject(planDisplayName: string): string { + return `Your ${planDisplayName} plan is now active on ${getBrandConfig().name}` +} + +/** Echoes the sender's own subject line so the reply threads correctly. */ +export function getRequestConfirmationSubject(userSubject: string, requestType?: string): string { + return requestType + ? `Your ${requestType} request has been received: ${userSubject}` + : `We've received your message: ${userSubject}` +} + +/** Names the resource being unlocked rather than the brand — that is what the recipient opened. */ +export function getOtpSubject(resourceLabel: string): string { + return `Verification code for ${resourceLabel}` +} diff --git a/apps/sim/ee/sso/components/sso-form.test.tsx b/apps/sim/ee/sso/components/sso-form.test.tsx index cd1dc712c46..7a6f2dc0af7 100644 --- a/apps/sim/ee/sso/components/sso-form.test.tsx +++ b/apps/sim/ee/sso/components/sso-form.test.tsx @@ -44,9 +44,9 @@ vi.mock('@/lib/core/config/env', () => ({ import SSOForm from '@/ee/sso/components/sso-form' -function renderFirstFrame(search: string): string { +function renderFirstFrame(search: string, registrationDisabled = false): string { mockUseSearchParams.mockReturnValue(new URLSearchParams(search)) - return renderToString() + return renderToString() } /** @@ -78,3 +78,24 @@ describe('SSOForm callback URL', () => { expect(html).toContain(`/login?callbackUrl=${encodeURIComponent('/workspace')}`) }) }) + +describe('SSOForm signup cross-link', () => { + beforeEach(() => { + mockUseSearchParams.mockReset() + }) + + it('offers signup when registration is enabled', () => { + const html = renderFirstFrame('') + + expect(html).toContain('Don't have an account?') + expect(html).toContain('/signup') + }) + + /** `/signup` rejects the visitor server-side, so linking there is a dead end. */ + it('hides signup when registration is disabled', () => { + const html = renderFirstFrame('', true) + + expect(html).not.toContain('Don't have an account?') + expect(html).not.toContain('/signup') + }) +}) diff --git a/apps/sim/ee/sso/components/sso-form.tsx b/apps/sim/ee/sso/components/sso-form.tsx index 7cf40063bbb..f2c3cd5f4c6 100644 --- a/apps/sim/ee/sso/components/sso-form.tsx +++ b/apps/sim/ee/sso/components/sso-form.tsx @@ -29,7 +29,12 @@ const validateEmailField = (emailValue: string): string[] => { return errors } -export default function SSOForm() { +interface SSOFormProps { + /** DISABLE_REGISTRATION. Hides the signup cross-link, which `/signup` blocks. */ + registrationDisabled: boolean +} + +export default function SSOForm({ registrationDisabled }: SSOFormProps) { const router = useRouter() const searchParams = useSearchParams() const [isLoading, setIsLoading] = useState(false) @@ -215,7 +220,7 @@ export default function SSOForm() { )} - {emailEnabled && ( + {emailEnabled && !registrationDisabled && (
Don't have an account? new AgentToolInputSafetyError(message) + const AGENT_RAW_PROVIDER_ERROR_INPUT_PATHS: readonly ResolvedSecretInputPath[] = [ ['model'], ['temperature'], @@ -237,7 +243,13 @@ export class AgentBlockHandler implements BlockHandler { const privateAgentSelectors = this.getPrivateAgentSelectorInputPaths(ctx, inputs, []) privateAgentSelectorInputPaths.push(...privateAgentSelectors.inputPaths) if (!privateAgentSelectors.complete) { - throw new AgentToolInputSafetyError('Agent private selector could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.privateSelectorProvenance', + message: AGENT_PRIVATE_SELECTOR_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'responseFormat,tools,skills', + createError: toAgentToolInputSafetyError, + }) } const responseFormatProjection = this.projectResponseFormatForModel( ctx, @@ -267,7 +279,11 @@ export class AgentBlockHandler implements BlockHandler { coreModelInputPaths ) if (!modelInputProjection.complete) { - throw new Error('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.coreModelInput', + message: AGENT_MODEL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + }) } const modelInputs: AgentInputs = { ...filteredInputs, @@ -652,7 +668,12 @@ export class AgentBlockHandler implements BlockHandler { const projection = registry.projectResolvedInputSelection({ tools: inputTools }) if (!projection.complete || !Array.isArray(projection.value.tools)) { - throw new Error('Agent tool input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.toolInputProvenanceProjection', + message: AGENT_TOOL_INPUT_REFUSAL, + registry, + inputPath: 'tools', + }) } return projection.value.tools as ToolInput[] } @@ -795,7 +816,12 @@ export class AgentBlockHandler implements BlockHandler { const provenance = registry.exportCommittedProvenanceForInputPaths(inputPaths) if (!provenance.complete) { - throw new AgentToolInputSafetyError('Agent tool input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.structuralInputProvenance', + message: AGENT_TOOL_INPUT_REFUSAL, + registry, + createError: toAgentToolInputSafetyError, + }) } if (provenance.entries.length > 0) { throw new AgentToolInputSafetyError(errorMessage) @@ -891,14 +917,26 @@ export class AgentBlockHandler implements BlockHandler { return null } if (!modelSchema?.function) { - throw new AgentToolInputSafetyError('Agent tool input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.customToolModelSchemaMissing', + message: AGENT_TOOL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'tools', + createError: toAgentToolInputSafetyError, + }) } const parametersProjection = projectModelSchemaAnnotations( schema.function.parameters, modelSchema.function.parameters ) if (!parametersProjection.safe) { - throw new AgentToolInputSafetyError('Agent tool input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.customToolSchemaAnnotations', + message: AGENT_TOOL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'tools', + createError: toAgentToolInputSafetyError, + }) } const rawDescription = schema.function.description const projectedDescription = modelSchema.function.description @@ -906,7 +944,13 @@ export class AgentBlockHandler implements BlockHandler { (rawDescription === undefined && projectedDescription !== undefined) || (rawDescription !== undefined && projectedDescription === undefined) ) { - throw new AgentToolInputSafetyError('Agent tool input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.customToolDescriptionArity', + message: AGENT_TOOL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'tools', + createError: toAgentToolInputSafetyError, + }) } const modelParameters = parametersProjection.value as ToolSchema @@ -1081,18 +1125,36 @@ export class AgentBlockHandler implements BlockHandler { const { serverId, toolName, serverName, ...userProvidedParams } = tool.params || {} const projectedSchema = projectedTool?.schema ?? tool.schema if (projectedSchema !== undefined && !isPlainRecord(projectedSchema)) { - throw new AgentToolInputSafetyError('Agent tool input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.mcpToolSchemaShape', + message: AGENT_TOOL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'tools', + createError: toAgentToolInputSafetyError, + }) } const schemaProjection = projectModelSchemaAnnotations(tool.schema, projectedSchema) if (!schemaProjection.safe || !isPlainRecord(schemaProjection.value)) { - throw new AgentToolInputSafetyError('Agent tool input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.mcpToolSchemaAnnotations', + message: AGENT_TOOL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'tools', + createError: toAgentToolInputSafetyError, + }) } const projectedServerName = typeof projectedTool?.params?.serverName === 'string' ? projectedTool.params.serverName : serverName if (schemaProjection.value.type !== 'object') { - throw new AgentToolInputSafetyError('Agent tool input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.mcpToolSchemaType', + message: AGENT_TOOL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'tools', + createError: toAgentToolInputSafetyError, + }) } const schema: McpToolSchema = { ...schemaProjection.value, type: 'object' } const schemaDescription = @@ -1407,7 +1469,12 @@ export class AgentBlockHandler implements BlockHandler { .pop() if (latestUserFromInput) { if (!latestRawUserFromInput) { - throw new Error('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.memoryUserMessageArity', + message: AGENT_MODEL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'messages', + }) } const userMessageInThisRun = memoryMessages.some( (m) => m.role === 'user' && m.executionId === ctx.executionId @@ -1499,7 +1566,12 @@ export class AgentBlockHandler implements BlockHandler { } const projectedFiles = normalizeFileInput(projectedFilesInput) if (!projectedFiles || projectedFiles.length !== normalizedFiles.length) { - throw new Error('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.fileInputArity', + message: AGENT_MODEL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'files', + }) } if (!messages || messages.length === 0) { @@ -1525,7 +1597,12 @@ export class AgentBlockHandler implements BlockHandler { const projectedFile = projectedFiles[index] if (!isPlainRecord(projectedFile) || typeof projectedFile.name !== 'string') { - throw new Error('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.fileInputShape', + message: AGENT_MODEL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'files', + }) } const rawName = isPlainRecord(file) ? file.name : undefined if (typeof rawName === 'string' && projectedFile.name !== rawName) { @@ -1826,15 +1903,20 @@ export class AgentBlockHandler implements BlockHandler { const displayInputPaths = privateRoots.has('responseFormat') ? [...privateInputPaths, ['responseFormat']] : privateInputPaths - const displayProjection = sourceRegistry - .forkForInputPaths(displayInputPaths) - .projectResolvedInputSelection({ - responseFormat: inputs.responseFormat, - tools: inputs.tools, - skills: inputs.skills, - }) + const displayRegistry = sourceRegistry.forkForInputPaths(displayInputPaths) + const displayProjection = displayRegistry.projectResolvedInputSelection({ + responseFormat: inputs.responseFormat, + tools: inputs.tools, + skills: inputs.skills, + }) if (!displayProjection.complete) { - throw new AgentToolInputSafetyError('Agent private selector could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.privateSelectorDisplayProjection', + message: AGENT_PRIVATE_SELECTOR_REFUSAL, + registry: displayRegistry, + inputPath: 'responseFormat,tools,skills', + createError: toAgentToolInputSafetyError, + }) } if (privateRoots.has('responseFormat')) { inputs.responseFormat = displayProjection.value @@ -1842,13 +1924,25 @@ export class AgentBlockHandler implements BlockHandler { } if (privateRoots.has('tools')) { if (!Array.isArray(displayProjection.value.tools)) { - throw new AgentToolInputSafetyError('Agent private selector could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.privateSelectorToolsShape', + message: AGENT_PRIVATE_SELECTOR_REFUSAL, + registry: displayRegistry, + inputPath: 'tools', + createError: toAgentToolInputSafetyError, + }) } inputs.tools = displayProjection.value.tools as ToolInput[] } if (privateRoots.has('skills')) { if (!Array.isArray(displayProjection.value.skills)) { - throw new AgentToolInputSafetyError('Agent private selector could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.privateSelectorSkillsShape', + message: AGENT_PRIVATE_SELECTOR_REFUSAL, + registry: displayRegistry, + inputPath: 'skills', + createError: toAgentToolInputSafetyError, + }) } inputs.skills = displayProjection.value.skills as AgentInputs['skills'] } @@ -1921,7 +2015,13 @@ export class AgentBlockHandler implements BlockHandler { } const projection = registry.projectResolvedInputSelection({ responseFormat }) if (!projection.complete) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.responseFormatProjection', + message: AGENT_MODEL_INPUT_REFUSAL, + registry, + inputPath: 'responseFormat', + createError: toAgentToolInputSafetyError, + }) } const projectedResponseFormat = projection.value.responseFormat @@ -1930,20 +2030,36 @@ export class AgentBlockHandler implements BlockHandler { return { value: responseFormat, inputPaths: annotationInputPaths } } if (typeof projectedResponseFormat !== 'string') { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.responseFormatStringType', + message: AGENT_MODEL_INPUT_REFUSAL, + registry, + inputPath: 'responseFormat', + createError: toAgentToolInputSafetyError, + }) } try { const rawParsed = JSON.parse(responseFormat) const projectedParsed = JSON.parse(projectedResponseFormat) if (!isPlainRecord(rawParsed) || !isPlainRecord(projectedParsed)) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.responseFormatJsonShape', + message: AGENT_MODEL_INPUT_REFUSAL, + registry, + inputPath: 'responseFormat', + createError: toAgentToolInputSafetyError, + }) } const privateNameInputPaths = Object.hasOwn(rawParsed, 'name') && !Object.is(rawParsed.name, projectedParsed.name) ? ([['responseFormat']] as const) : [] onPrivateNameInputPaths(privateNameInputPaths) - const modelSafeResponseFormat = this.projectResponseFormatObject(rawParsed, projectedParsed) + const modelSafeResponseFormat = this.projectResponseFormatObject( + rawParsed, + projectedParsed, + registry + ) const parsedIsWrapper = Object.hasOwn(rawParsed, 'schema') || Object.hasOwn(rawParsed, 'name') const parsedSchema = parsedIsWrapper ? rawParsed.schema : rawParsed @@ -1963,13 +2079,25 @@ export class AgentBlockHandler implements BlockHandler { } } catch (error) { if (error instanceof AgentToolInputSafetyError) throw error - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.responseFormatJsonParse', + message: AGENT_MODEL_INPUT_REFUSAL, + registry, + inputPath: 'responseFormat', + createError: toAgentToolInputSafetyError, + }) } } if (!isPlainRecord(responseFormat)) { if (!Object.is(responseFormat, projectedResponseFormat)) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.responseFormatScalarIdentity', + message: AGENT_MODEL_INPUT_REFUSAL, + registry, + inputPath: 'responseFormat', + createError: toAgentToolInputSafetyError, + }) } return { value: responseFormat, @@ -1989,23 +2117,40 @@ export class AgentBlockHandler implements BlockHandler { 'Agent structural model inputs cannot contain secret references' ) return { - value: this.projectResponseFormatObject(responseFormat, projectedResponseFormat), + value: this.projectResponseFormatObject(responseFormat, projectedResponseFormat, registry), inputPaths: annotationInputPaths, } } + /** + * Takes the registry from its caller so a refusal here reports the run that failed; without it + * the refusal would deduplicate process-wide and name no cause. + */ private projectResponseFormatObject( rawValue: Record, - projectedValue: unknown + projectedValue: unknown, + registry: ResolvedSecretTraceRegistry ): Record { if (!isPlainRecord(projectedValue)) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.responseFormatObjectShape', + message: AGENT_MODEL_INPUT_REFUSAL, + registry, + inputPath: 'responseFormat', + createError: toAgentToolInputSafetyError, + }) } const isWrapper = Object.hasOwn(rawValue, 'schema') || Object.hasOwn(rawValue, 'name') if (!isWrapper) { const schemaProjection = projectModelSchemaAnnotations(rawValue, projectedValue) if (!schemaProjection.safe || !isPlainRecord(schemaProjection.value)) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.responseFormatSchemaAnnotations', + message: AGENT_MODEL_INPUT_REFUSAL, + registry, + inputPath: 'responseFormat', + createError: toAgentToolInputSafetyError, + }) } return schemaProjection.value } @@ -2015,16 +2160,34 @@ export class AgentBlockHandler implements BlockHandler { rawKeys.length !== Object.keys(projectedValue).length || rawKeys.some((key) => !Object.hasOwn(projectedValue, key)) ) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.responseFormatWrapperKeys', + message: AGENT_MODEL_INPUT_REFUSAL, + registry, + inputPath: 'responseFormat', + createError: toAgentToolInputSafetyError, + }) } for (const key of rawKeys) { if (key !== 'schema' && key !== 'name' && !Object.is(rawValue[key], projectedValue[key])) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.responseFormatWrapperValues', + message: AGENT_MODEL_INPUT_REFUSAL, + registry, + inputPath: 'responseFormat', + createError: toAgentToolInputSafetyError, + }) } } const schemaProjection = projectModelSchemaAnnotations(rawValue.schema, projectedValue.schema) if (!schemaProjection.safe) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.responseFormatWrapperSchemaAnnotations', + message: AGENT_MODEL_INPUT_REFUSAL, + registry, + inputPath: 'responseFormat', + createError: toAgentToolInputSafetyError, + }) } return { ...rawValue, @@ -2076,7 +2239,13 @@ export class AgentBlockHandler implements BlockHandler { inputPaths ) if (!projection.complete) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.fileNameProjection', + message: AGENT_MODEL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'files,messages', + createError: toAgentToolInputSafetyError, + }) } let projectedFiles = projection.value.files @@ -2091,23 +2260,47 @@ export class AgentBlockHandler implements BlockHandler { files: inputs.files, }) if (!serializedProjection.complete) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.serializedFilesProjection', + message: AGENT_MODEL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'files', + createError: toAgentToolInputSafetyError, + }) } const projectedSerializedFiles = serializedProjection.value.files if (!Object.is(inputs.files, projectedSerializedFiles)) { if (typeof projectedSerializedFiles !== 'string') { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.serializedFilesType', + message: AGENT_MODEL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'files', + createError: toAgentToolInputSafetyError, + }) } const rawFiles = normalizeFileInput(inputs.files) const projectedFileRecords = normalizeFileInput(projectedSerializedFiles) if (!rawFiles || !projectedFileRecords || rawFiles.length !== projectedFileRecords.length) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.serializedFilesArity', + message: AGENT_MODEL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'files', + createError: toAgentToolInputSafetyError, + }) } projectedFiles = rawFiles.map((rawFile, index) => { const projectedFile = projectedFileRecords[index] if (!isPlainRecord(rawFile) || !isPlainRecord(projectedFile)) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.serializedFileShape', + message: AGENT_MODEL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'files', + createError: toAgentToolInputSafetyError, + }) } if (!Object.is(rawFile.base64, projectedFile.base64)) { throw new AgentToolInputSafetyError( @@ -2116,7 +2309,13 @@ export class AgentBlockHandler implements BlockHandler { } if (rawFile.name === undefined) return rawFile if (typeof projectedFile.name !== 'string') { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.serializedFileName', + message: AGENT_MODEL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'files,name', + createError: toAgentToolInputSafetyError, + }) } return { ...rawFile, name: projectedFile.name } }) @@ -2137,7 +2336,13 @@ export class AgentBlockHandler implements BlockHandler { const projectedFiles = isPlainRecord(projectedMessage) ? projectedMessage.files : undefined if (!Array.isArray(rawFiles) || !Array.isArray(projectedFiles)) continue if (rawFiles.length !== projectedFiles.length) { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.messageFilesArity', + message: AGENT_MODEL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'messages,files', + createError: toAgentToolInputSafetyError, + }) } for (let fileIndex = 0; fileIndex < rawFiles.length; fileIndex++) { const rawFile = rawFiles[fileIndex] @@ -2145,7 +2350,13 @@ export class AgentBlockHandler implements BlockHandler { if (!isPlainRecord(rawFile) || !isPlainRecord(projectedFile)) continue if (rawFile.name === undefined) continue if (typeof projectedFile.name !== 'string') { - throw new AgentToolInputSafetyError('Agent model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'agent.messageFileName', + message: AGENT_MODEL_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'messages,files,name', + createError: toAgentToolInputSafetyError, + }) } if (Object.is(rawFile.name, projectedFile.name)) continue projectedNameByFile.set(rawFile, { diff --git a/apps/sim/executor/handlers/agent/memory.ts b/apps/sim/executor/handlers/agent/memory.ts index 4007ae539a7..13523c742ce 100644 --- a/apps/sim/executor/handlers/agent/memory.ts +++ b/apps/sim/executor/handlers/agent/memory.ts @@ -24,11 +24,14 @@ import { projectResolvedSecretModelContent, projectResolvedSecretModelJsonStrings, } from '@/executor/utils/resolved-secret-content-projection' +import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { PROVIDER_DEFINITIONS } from '@/providers/models' const logger = createLogger('Memory') +const MEMORY_CONTENT_REFUSAL = 'Memory content could not be safely projected' + export class Memory { async fetchMemoryMessages(ctx: ExecutionContext, inputs: AgentInputs): Promise { if (!inputs.memoryType || inputs.memoryType === 'none') { @@ -82,7 +85,12 @@ export class Memory { messages ))) ) { - throw new Error('Memory content could not be safely projected') + refuseResolvedSecretProjection({ + site: 'memory.storedProvenanceImport', + message: MEMORY_CONTENT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'messages', + }) } return Promise.all( @@ -95,7 +103,12 @@ export class Memory { ctx.resolvedSecretTraceRegistry?.exportProvenance().scope ) if (!(await importDurableSecretProvenance(modelRegistry, messageProvenance, message))) { - throw new Error('Memory content could not be safely projected') + refuseResolvedSecretProjection({ + site: 'memory.messageProvenanceImport', + message: MEMORY_CONTENT_REFUSAL, + registry: modelRegistry, + inputPath: 'messages', + }) } return this.projectMessageForModel(modelRegistry, message) }) @@ -213,12 +226,21 @@ export class Memory { } private projectMessageForModel(registry: ResolvedSecretTraceRegistry, message: Message): Message { - const functionArguments = this.readFunctionCallArguments(message.function_call) + const functionArguments = this.readFunctionCallArguments( + message.function_call, + registry, + 'function_call' + ) const toolArguments = message.tool_calls?.map((toolCall) => { if (!isPlainRecord(toolCall)) { - throw new Error('Memory content could not be safely projected') + refuseResolvedSecretProjection({ + site: 'memory.toolCallShape', + message: MEMORY_CONTENT_REFUSAL, + registry, + inputPath: 'tool_calls', + }) } - return this.readFunctionCallArguments(toolCall.function) + return this.readFunctionCallArguments(toolCall.function, registry, 'tool_calls.function') }) const contentProjection = projectResolvedSecretModelContent(message.content, registry) const argumentProjection = projectResolvedSecretModelJsonStrings( @@ -232,7 +254,12 @@ export class Memory { !Array.isArray(argumentProjection.value) || argumentProjection.value.length !== 1 + (toolArguments?.length ?? 0) ) { - throw new Error('Memory content could not be safely projected') + refuseResolvedSecretProjection({ + site: 'memory.messageContentProjection', + message: MEMORY_CONTENT_REFUSAL, + registry, + inputPath: 'content,function_call,tool_calls', + }) } const content = contentProjection.value @@ -241,13 +268,23 @@ export class Memory { (functionArguments !== undefined && typeof projectedFunctionArguments !== 'string') || (functionArguments === undefined && projectedFunctionArguments !== undefined) ) { - throw new Error('Memory content could not be safely projected') + refuseResolvedSecretProjection({ + site: 'memory.functionCallArgumentProjection', + message: MEMORY_CONTENT_REFUSAL, + registry, + inputPath: 'function_call.arguments', + }) } if ( (toolArguments !== undefined && projectedToolArguments.length !== toolArguments.length) || (toolArguments === undefined && projectedToolArguments.length !== 0) ) { - throw new Error('Memory content could not be safely projected') + refuseResolvedSecretProjection({ + site: 'memory.toolCallArgumentArity', + message: MEMORY_CONTENT_REFUSAL, + registry, + inputPath: 'tool_calls.function.arguments', + }) } const projectedToolCalls = message.tool_calls?.map((toolCall, index) => { @@ -255,11 +292,21 @@ export class Memory { const originalFunction = isPlainRecord(toolCall) ? toolCall.function : undefined if (originalFunction === undefined || originalFunction === null) return toolCall if (!isPlainRecord(originalFunction)) { - throw new Error('Memory content could not be safely projected') + refuseResolvedSecretProjection({ + site: 'memory.toolCallFunctionShape', + message: MEMORY_CONTENT_REFUSAL, + registry, + inputPath: 'tool_calls.function', + }) } if (!Object.hasOwn(originalFunction, 'arguments')) return toolCall if (typeof argument !== 'string') { - throw new Error('Memory content could not be safely projected') + refuseResolvedSecretProjection({ + site: 'memory.toolCallArgumentType', + message: MEMORY_CONTENT_REFUSAL, + registry, + inputPath: 'tool_calls.function.arguments', + }) } return { ...toolCall, @@ -283,14 +330,32 @@ export class Memory { } } - private readFunctionCallArguments(functionCall: unknown): string | undefined { + /** + * Takes the registry and path from its caller so a refusal here reports the run that failed. + * Without them the refusal would deduplicate process-wide and name no cause. + */ + private readFunctionCallArguments( + functionCall: unknown, + registry: ResolvedSecretTraceRegistry, + inputPath: string + ): string | undefined { if (functionCall === undefined || functionCall === null) return undefined if (!isPlainRecord(functionCall)) { - throw new Error('Memory content could not be safely projected') + refuseResolvedSecretProjection({ + site: 'memory.functionCallShape', + message: MEMORY_CONTENT_REFUSAL, + registry, + inputPath, + }) } if (!Object.hasOwn(functionCall, 'arguments')) return undefined if (typeof functionCall.arguments !== 'string') { - throw new Error('Memory content could not be safely projected') + refuseResolvedSecretProjection({ + site: 'memory.functionCallArgumentType', + message: MEMORY_CONTENT_REFUSAL, + registry, + inputPath, + }) } return functionCall.arguments } diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts index 16ba1c8e831..6a094f64082 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.ts @@ -18,6 +18,7 @@ import type { BlockHandler, ExecutionContext } from '@/executor/types' import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http' import { isJSONString, parseJSON, stringifyJSON } from '@/executor/utils/json' import { projectResolvedSecretDiagnosticError } from '@/executor/utils/resolved-secret-content-projection' +import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import type { ResolvedSecretInputPath, ResolvedSecretTraceRegistry, @@ -81,7 +82,12 @@ export class EvaluatorBlockHandler implements BlockHandler { modelInputPaths ) if (!modelInputProjection.complete) { - throw new Error('Evaluator model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'evaluator.contentMetricsModelInput', + message: 'Evaluator model input could not be safely projected', + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'content,metrics', + }) } const processedContent = this.processContent(modelInputProjection.value.content) const projectedMetrics = Array.isArray(modelInputProjection.value.metrics) diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.ts b/apps/sim/executor/handlers/mothership/mothership-handler.ts index 1c77fdaccb7..b3af3d828d5 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.ts @@ -43,6 +43,7 @@ import type { StreamingExecution, } from '@/executor/types' import { buildAPIUrl, buildAuthHeaders, extractAPIErrorMessage } from '@/executor/utils/http' +import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import type { ResolvedSecretInputPath, ResolvedSecretTraceRegistry, @@ -50,6 +51,10 @@ import type { import type { SerializedBlock } from '@/serializer/types' const logger = createLogger('MothershipBlockHandler') + +const MOTHERSHIP_INPUT_REFUSAL = 'Mothership input could not be safely projected' +const MOTHERSHIP_SKILL_SELECTOR_REFUSAL = + 'Mothership skill selector could not be safely projected for display' const CANCELLATION_CHECK_INTERVAL_MS = 500 const MAX_MOTHERSHIP_ATTACHMENT_BYTES = 10 * 1024 * 1024 const MOTHERSHIP_EXECUTE_STREAM_HEADER = 'X-Mothership-Execute-Stream' @@ -234,11 +239,15 @@ function projectPrivateMothershipSkillSelectorsForDisplay( privateSelectorInputPaths: readonly ResolvedSecretInputPath[] ): unknown { if (!Array.isArray(skills) || privateSelectorIndexes.size === 0) return skills - const projection = registry - .forkForInputPaths(privateSelectorInputPaths) - .projectResolvedInputSelection({ skills }) + const selectorRegistry = registry.forkForInputPaths(privateSelectorInputPaths) + const projection = selectorRegistry.projectResolvedInputSelection({ skills }) if (!projection.complete || !Array.isArray(projection.value.skills)) { - throw new Error('Mothership skill selector could not be safely projected for display') + refuseResolvedSecretProjection({ + site: 'mothership.skillSelectorDisplay', + message: MOTHERSHIP_SKILL_SELECTOR_REFUSAL, + registry: selectorRegistry, + inputPath: 'skills', + }) } for (const inputIndex of privateSelectorIndexes) { const source = skills[inputIndex] @@ -249,7 +258,12 @@ function projectPrivateMothershipSkillSelectorsForDisplay( typeof source.skillId !== 'string' || typeof projected.skillId !== 'string' ) { - throw new Error('Mothership skill selector could not be safely projected for display') + refuseResolvedSecretProjection({ + site: 'mothership.skillSelectorDisplayEntry', + message: MOTHERSHIP_SKILL_SELECTOR_REFUSAL, + registry: selectorRegistry, + inputPath: 'skills.skillId', + }) } } return projection.value.skills @@ -293,19 +307,34 @@ function assertMothershipToolSchemaProjectionsAreSafe( if (!Array.isArray(tools)) return const projection = registry.projectResolvedInputSelection({ tools }) if (!projection.complete || !Array.isArray(projection.value.tools)) { - throw new Error('Mothership input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'mothership.toolSchemaProjection', + message: MOTHERSHIP_INPUT_REFUSAL, + registry, + inputPath: 'tools', + }) } for (const { inputIndex, selection } of selectIndexedMothershipMcpTools(tools)) { if (!selection.schema) continue const projectedCandidate = projection.value.tools[inputIndex] if (!isPlainRecord(projectedCandidate)) { - throw new Error('Mothership input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'mothership.toolSchemaProjectedEntry', + message: MOTHERSHIP_INPUT_REFUSAL, + registry, + inputPath: 'tools.schema', + }) } const projectedSchema = projectedCandidate.schema ?? selection.schema const schemaProjection = projectModelSchemaAnnotations(selection.schema, projectedSchema) if (!schemaProjection.safe) { - throw new Error('Mothership input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'mothership.toolSchemaAnnotations', + message: MOTHERSHIP_INPUT_REFUSAL, + registry, + inputPath: 'tools.schema', + }) } } } @@ -316,7 +345,11 @@ function assertMothershipStructuralInputsDoNotResolveSecrets( ): void { const provenance = registry.exportCommittedProvenanceForInputPaths(inputPaths) if (!provenance.complete) { - throw new Error('Mothership input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'mothership.structuralInputProvenance', + message: MOTHERSHIP_INPUT_REFUSAL, + registry, + }) } if (provenance.entries.length > 0) { throw new Error('Mothership structural model inputs cannot contain secret references') @@ -640,7 +673,12 @@ async function buildMothershipFileAttachments( } const projectedFiles = normalizeFileInput(projectedFilesInput) if (!projectedFiles || projectedFiles.length !== files.length) { - throw new Error('Mothership input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'mothership.fileAttachmentArity', + message: MOTHERSHIP_INPUT_REFUSAL, + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'files', + }) } const userFiles = files.map((file) => @@ -767,7 +805,12 @@ export class MothershipBlockHandler implements BlockHandler { modelInputPaths ) if (!modelInputProjection.complete || typeof modelInputProjection.value.prompt !== 'string') { - throw new Error('Mothership input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'mothership.modelInput', + message: MOTHERSHIP_INPUT_REFUSAL, + registry: sourceRegistry, + inputPath: 'prompt,files,tools,skills', + }) } const messages = [ { diff --git a/apps/sim/executor/handlers/pi/pi-handler.ts b/apps/sim/executor/handlers/pi/pi-handler.ts index 4d8a1f36ca6..2f42719b71c 100644 --- a/apps/sim/executor/handlers/pi/pi-handler.ts +++ b/apps/sim/executor/handlers/pi/pi-handler.ts @@ -53,6 +53,7 @@ import type { NormalizedBlockOutput, StreamingExecution, } from '@/executor/types' +import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import { isPiSupportedProvider, resolvePiModelId } from '@/providers/pi-providers' import { getProviderFromModel } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' @@ -172,7 +173,12 @@ export class PiBlockHandler implements BlockHandler { [['task']] ) if (!taskProjection.complete || typeof taskProjection.value.task !== 'string') { - throw new Error('Pi input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'pi.taskModelInput', + message: 'Pi input could not be safely projected', + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'task', + }) } const task = taskProjection.value.task const model = asOptString(inputs.model) ?? DEFAULT_MODEL @@ -431,7 +437,12 @@ export class PiBlockHandler implements BlockHandler { [['searchApiKey']] ) if (!searchInputProjection.complete) { - throw new Error('Pi search input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'pi.searchApiKeyInput', + message: 'Pi search input could not be safely projected', + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'searchApiKey', + }) } const projectedApiKey = Object.is(searchInputProjection.value.searchApiKey, rawSearchApiKey) ? apiKey diff --git a/apps/sim/executor/handlers/router/router-handler.ts b/apps/sim/executor/handlers/router/router-handler.ts index 365453e64db..11e5445889a 100644 --- a/apps/sim/executor/handlers/router/router-handler.ts +++ b/apps/sim/executor/handlers/router/router-handler.ts @@ -24,6 +24,7 @@ import { } from '@/executor/constants' import type { BlockHandler, ExecutionContext } from '@/executor/types' import { buildAuthHeaders } from '@/executor/utils/http' +import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import type { ResolvedSecretInputPath } from '@/executor/utils/resolved-secret-trace-registry' import { resolveVertexCredential } from '@/executor/utils/vertex-credential' import { resolveProxiedModelCost } from '@/providers/cost-policy' @@ -80,7 +81,12 @@ export class RouterBlockHandler implements BlockHandler { promptModelInputPaths ) if (!modelInputProjection.complete) { - throw new Error('Router model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'router.promptModelInput', + message: 'Router model input could not be safely projected', + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'prompt', + }) } const targetBlocks = this.getTargetBlocks(ctx, block) @@ -251,11 +257,21 @@ export class RouterBlockHandler implements BlockHandler { modelInputPaths ) if (!modelInputProjection.complete) { - throw new Error('Router model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'router.contextModelInput', + message: 'Router model input could not be safely projected', + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'context,routes', + }) } const projectedRoutes = this.parseRoutes(modelInputProjection.value.routes) if (projectedRoutes.length !== routes.length) { - throw new Error('Router model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'router.routeArity', + message: 'Router model input could not be safely projected', + registry: ctx.resolvedSecretTraceRegistry, + inputPath: 'routes', + }) } const modelRoutes = routes.map((route, index) => ({ ...route, diff --git a/apps/sim/executor/utils/resolved-secret-projection-refusal.test.ts b/apps/sim/executor/utils/resolved-secret-projection-refusal.test.ts new file mode 100644 index 00000000000..17cdfef8d63 --- /dev/null +++ b/apps/sim/executor/utils/resolved-secret-projection-refusal.test.ts @@ -0,0 +1,218 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockLogger } = vi.hoisted(() => ({ + mockLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => mockLogger, +})) + +import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' +import { + createIncompleteResolvedSecretTraceRegistry, + ResolvedSecretTraceRegistry, +} from '@/executor/utils/resolved-secret-trace-registry' + +const scope = { userId: 'user-1', workspaceId: 'workspace-1' } + +function refusalRecords() { + return mockLogger.error.mock.calls.filter( + ([message]) => message === 'Resolved secret projection refused' + ) +} + +describe('refuseResolvedSecretProjection', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('throws the call site message unchanged so the user-facing wording never drifts', () => { + const registry = new ResolvedSecretTraceRegistry([], scope) + registry.markIncomplete('projection-mismatch') + + expect(() => + refuseResolvedSecretProjection({ + site: 'router.promptModelInput', + message: 'Router model input could not be safely projected', + registry, + }) + ).toThrow('Router model input could not be safely projected') + }) + + it('uses the call site error type when one is needed for control flow', () => { + class ToolInputSafetyError extends Error {} + const registry = new ResolvedSecretTraceRegistry([], scope) + registry.markIncomplete('projection-mismatch') + + expect(() => + refuseResolvedSecretProjection({ + site: 'agent.toolInput', + message: 'Agent tool input could not be safely projected', + registry, + createError: (message) => new ToolInputSafetyError(message), + }) + ).toThrow(ToolInputSafetyError) + }) + + it('reports the guard that caused the refusal, not merely that one occurred', () => { + const registry = new ResolvedSecretTraceRegistry([], scope) + registry.markIncomplete('projection-mismatch') + + expect(() => + refuseResolvedSecretProjection({ + site: 'router.promptModelInput', + message: 'Router model input could not be safely projected', + registry, + inputPath: 'prompt', + }) + ).toThrow() + + expect(refusalRecords()).toHaveLength(1) + expect(refusalRecords()[0][1]).toEqual( + expect.objectContaining({ + site: 'router.promptModelInput', + inputPath: 'prompt', + reason: 'projection-mismatch', + scopeWorkspaceId: 'workspace-1', + }) + ) + }) + + it('names a by-design origin that was silenced when it was marked', () => { + const registry = createIncompleteResolvedSecretTraceRegistry(scope) + expect(mockLogger.error).not.toHaveBeenCalled() + expect(mockLogger.warn).not.toHaveBeenCalled() + + expect(() => + refuseResolvedSecretProjection({ + site: 'agent.coreModelInput', + message: 'Agent model input could not be safely projected', + registry, + }) + ).toThrow() + + expect(refusalRecords()[0][1]).toEqual( + expect.objectContaining({ reason: 'constructed-incomplete' }) + ) + }) + + it('reports the originating guard through a fork that only inherited it', () => { + const parent = new ResolvedSecretTraceRegistry([], scope) + parent.markIncomplete('entry-decrypt-failed') + const fork = parent.forkForToolCall() + mockLogger.error.mockClear() + + expect(() => + refuseResolvedSecretProjection({ + site: 'agent.toolInput', + message: 'Agent tool input could not be safely projected', + registry: fork, + }) + ).toThrow() + + const details = refusalRecords()[0][1] as { reason: string; reasons: string[]; cause: string } + expect(details.reason).toBe('entry-decrypt-failed') + expect(details.reasons).toContain('inherited-incomplete-source') + expect(details.cause).toBe('registry-latched') + }) + + it('reports a repeated boundary once per registry, so a loop cannot flood', () => { + const registry = new ResolvedSecretTraceRegistry([], scope) + registry.markIncomplete('projection-mismatch') + + for (let iteration = 0; iteration < 25; iteration++) { + expect(() => + refuseResolvedSecretProjection({ + site: 'agent.toolInput', + message: 'Agent tool input could not be safely projected', + registry, + }) + ).toThrow() + } + + expect(refusalRecords()).toHaveLength(1) + }) + + it('reports distinct boundaries separately within one registry', () => { + const registry = new ResolvedSecretTraceRegistry([], scope) + registry.markIncomplete('projection-mismatch') + + for (const site of ['agent.coreModelInput', 'agent.toolInput']) { + expect(() => refuseResolvedSecretProjection({ site, message: 'refused', registry })).toThrow() + } + + expect(refusalRecords().map(([, d]) => (d as { site: string }).site)).toEqual([ + 'agent.coreModelInput', + 'agent.toolInput', + ]) + }) + + it('separates a latched registry from a caller-side cross-check', () => { + const complete = new ResolvedSecretTraceRegistry([], scope) + + expect(() => + refuseResolvedSecretProjection({ + site: 'agent.responseFormatObjectShape', + message: 'Agent model input could not be safely projected', + registry: complete, + }) + ).toThrow() + + expect(refusalRecords()[0][1]).toEqual( + expect.objectContaining({ cause: 'projection-cross-check', registryPresent: true }) + ) + }) + + it('reports the same boundary separately for different input paths', () => { + const registry = new ResolvedSecretTraceRegistry([], scope) + registry.markIncomplete('projection-mismatch') + + for (const inputPath of ['function_call', 'tool_calls.function']) { + expect(() => + refuseResolvedSecretProjection({ + site: 'memory.functionCallShape', + message: 'Memory content could not be safely projected', + registry, + inputPath, + }) + ).toThrow() + } + + expect(refusalRecords()).toHaveLength(2) + }) + + it('reports a registry-less refusal every time, since a later request is a new incident', () => { + for (let request = 0; request < 3; request++) { + expect(() => + refuseResolvedSecretProjection({ + site: 'copilot.initialAttachmentsShape', + message: 'Copilot model input could not be safely projected', + }) + ).toThrow() + } + + expect(refusalRecords()).toHaveLength(3) + }) + + it('records no secret material', () => { + const registry = new ResolvedSecretTraceRegistry( + [{ name: 'API_KEY', plaintext: 'super-secret-value', encryptedValue: 'encrypted' }], + scope + ) + registry.recordResolved('API_KEY', 'super-secret-value') + registry.markIncomplete('projection-mismatch') + + expect(() => + refuseResolvedSecretProjection({ + site: 'agent.coreModelInput', + message: 'Agent model input could not be safely projected', + registry, + }) + ).toThrow() + + const logged = JSON.stringify(refusalRecords()) + expect(logged).not.toContain('super-secret-value') + expect(logged).not.toContain('API_KEY') + }) +}) diff --git a/apps/sim/executor/utils/resolved-secret-projection-refusal.ts b/apps/sim/executor/utils/resolved-secret-projection-refusal.ts new file mode 100644 index 00000000000..bd8837d7245 --- /dev/null +++ b/apps/sim/executor/utils/resolved-secret-projection-refusal.ts @@ -0,0 +1,98 @@ +import { createLogger } from '@sim/logger' +import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +const logger = createLogger('ResolvedSecretProjectionRefusal') + +/** + * Reporting is deduplicated per registry, because one run can refuse the same boundary repeatedly: + * an agent projects tool input on every iteration of its loop, and a single latched registry would + * otherwise emit a line per iteration. + */ +const reportedSitesByRegistry = new WeakMap>() + +export interface ResolvedSecretProjectionRefusal { + /** + * Stable dotted identifier for the boundary that refused, e.g. `agent.modelInput`. Chosen by the + * call site rather than derived, so it survives refactors and stays greppable. + */ + site: string + /** The message thrown to the user. Callers pass their existing text so wording never changes. */ + message: string + /** The registry whose incompleteness caused the refusal. */ + registry?: ResolvedSecretTraceRegistry + /** + * Field names of the path being projected, comma-separated when several are covered at once. + * Never a resolved value. + */ + inputPath?: string + /** Builds the thrown error when a call site needs its own type for control flow. */ + createError?: (message: string) => Error +} + +/** + * Records why a projection was refused, then throws the call site's own error. + * + * Every refusal reaches the user as the same fixed sentence whichever guard caused it, and that + * guard may have tripped many frames — or a whole process — earlier, so this is the only point + * where the failing boundary and the cause are both in hand. + * + * Returns `never`, so `if (!projection.complete) refuseResolvedSecretProjection(...)` still narrows + * the projection for the code that follows. + */ +export function refuseResolvedSecretProjection(refusal: ResolvedSecretProjectionRefusal): never { + reportRefusal(refusal) + const message = refusal.message + throw refusal.createError ? refusal.createError(message) : new Error(message) +} + +function reportRefusal({ site, registry, inputPath }: ResolvedSecretProjectionRefusal): void { + if (!shouldReport(dedupKey(site, inputPath), registry)) return + + const diagnostics = registry?.getIncompletenessDiagnostics() + logger.error('Resolved secret projection refused', { + site, + ...(inputPath ? { inputPath } : {}), + /** + * Separates the two failure families that reach this one event: a registry that latched and + * genuinely cannot vouch, versus a caller finding the projection's own output malformed. Only + * the former carries reasons, so a query filtered on `reason` would otherwise silently cover + * half of them. + */ + cause: diagnostics ? 'registry-latched' : 'projection-cross-check', + registryPresent: registry !== undefined, + ...(diagnostics + ? { + reason: diagnostics.reasons[0], + reasons: diagnostics.reasons, + incompleteInputPathCount: diagnostics.incompleteInputPathCount, + activeEntryCount: diagnostics.activeEntryCount, + ...(diagnostics.scopeWorkspaceId + ? { scopeWorkspaceId: diagnostics.scopeWorkspaceId } + : {}), + } + : {}), + }) +} + +/** Distinguishes the same boundary refusing on different paths, which are different incidents. */ +function dedupKey(site: string, inputPath: string | undefined): string { + return inputPath ? `${site}\u0000${inputPath}` : site +} + +/** + * Deduplicates only against a registry, whose lifetime is the run that refused. + * + * A refusal with no registry is never deduplicated: those sites abort the request rather than + * iterate, so each reaches this at most once per request, and any process-wide memory of them would + * silence every later request — including the one being investigated. A new registry-less site + * placed inside a loop would therefore repeat; give it a registry instead. + */ +function shouldReport(key: string, registry: ResolvedSecretTraceRegistry | undefined): boolean { + if (!registry) return true + + const reported = reportedSitesByRegistry.get(registry) ?? new Set() + reportedSitesByRegistry.set(registry, reported) + if (reported.has(key)) return false + reported.add(key) + return true +} diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts index f8906be8e12..0db5b2e881f 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts @@ -1453,6 +1453,51 @@ describe('incompleteness diagnostics', () => { ) }) + it('reports no diagnostics while it can still vouch', () => { + const registry = new ResolvedSecretTraceRegistry([], scope) + + expect(registry.getIncompletenessDiagnostics()).toBeUndefined() + }) + + it('retains the causal order of reasons, keeping the first as the originating one', () => { + const registry = new ResolvedSecretTraceRegistry([], scope) + + registry.markIncomplete('entry-decrypt-failed') + registry.markIncomplete('source-provenance-incomplete') + + const diagnostics = registry.getIncompletenessDiagnostics() + expect(diagnostics?.reasons[0]).toBe('entry-decrypt-failed') + expect(diagnostics?.reasons).toEqual(['entry-decrypt-failed', 'source-provenance-incomplete']) + }) + + it('retains every distinct reason, since the reason type is what bounds the set', () => { + const registry = new ResolvedSecretTraceRegistry([], scope) + const reasons = [ + 'entry-decrypt-failed', + 'source-provenance-incomplete', + 'projection-mismatch', + 'unresolved-placeholder', + 'provenance-capacity-exceeded', + 'tool-call-scope-mismatch', + 'untrusted-provenance', + 'value-provenance-untrusted', + 'value-provenance-import-failed', + 'unverified-resolved-entry', + ] as const + + for (const reason of reasons) registry.markIncomplete(reason) + + expect(registry.getIncompletenessDiagnostics()?.reasons).toEqual([...reasons]) + }) + + it('retains a by-design reason even though marking it reports nothing', () => { + const registry = createIncompleteResolvedSecretTraceRegistry(scope) + + expect(mockLogger.warn).not.toHaveBeenCalled() + expect(mockLogger.error).not.toHaveBeenCalled() + expect(registry.getIncompletenessDiagnostics()?.reasons[0]).toBe('constructed-incomplete') + }) + it('records no secret material alongside the reason', () => { const registry = new ResolvedSecretTraceRegistry([], scope) diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.ts index 348a4fba6f9..853573eae6d 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.ts @@ -22,7 +22,7 @@ const logger = createLogger('ResolvedSecretTraceRegistry') * genuine containment from a matcher that merely could not decide — the reasons are static * literals and the logged path is block/field names, never a resolved value. */ -type ResolvedSecretIncompletenessReason = +export type ResolvedSecretIncompletenessReason = | 'untrusted-provenance' | 'source-provenance-incomplete' | 'entry-decrypt-failed' @@ -75,6 +75,24 @@ const BY_DESIGN_INCOMPLETENESS_REASONS = new Set() private readonly resolvedInputPaths = new Map() private readonly incompleteInputPaths = new Map() + /** Insertion-ordered; see {@link ResolvedSecretIncompletenessDiagnostics}. */ + private readonly incompletenessReasons = new Set() private activeProvenanceEntryBytes = 0 private complete = true private pendingActivations = 0 @@ -653,7 +673,7 @@ export class ResolvedSecretTraceRegistry { } this.copyResolvedInputPathsTo(fork) this.copyIncompleteInputPathsTo(fork) - if (!this.complete) fork.markIncomplete('inherited-incomplete-source') + if (!this.complete) fork.markIncomplete('inherited-incomplete-source', this) return fork } @@ -664,12 +684,12 @@ export class ResolvedSecretTraceRegistry { ): ResolvedSecretTraceRegistry { const fork = new ResolvedSecretTraceRegistry(this.catalog.values(), this.scope) if (!this.complete) { - fork.markIncomplete('inherited-incomplete-source') + fork.markIncomplete('inherited-incomplete-source', this) return fork } if (this.hasIncompleteInputPathOverlapping(paths)) { - fork.markIncomplete('inherited-incomplete-input-path') + fork.markIncomplete('inherited-incomplete-input-path', this) return fork } @@ -693,7 +713,7 @@ export class ResolvedSecretTraceRegistry { fork.addActiveEntry({ ...entry }, { propagated: true }) } } - if (this.isPermanentlyIncomplete()) fork.markIncomplete('inherited-incomplete-source') + if (this.isPermanentlyIncomplete()) fork.markIncomplete('inherited-incomplete-source', this) return fork } @@ -705,7 +725,7 @@ export class ResolvedSecretTraceRegistry { } if (!child.isComplete()) { - this.markIncomplete('inherited-incomplete-source') + this.markIncomplete('inherited-incomplete-source', child) return } @@ -1308,11 +1328,45 @@ export class ResolvedSecretTraceRegistry { return this.complete && this.incompleteInputPaths.size === 0 && this.pendingActivations === 0 } + /** + * Reports why this registry is incomplete, for a caller that is about to refuse a projection. + * + * Returns undefined while the registry can still vouch, so a caller cannot accidentally report a + * cause for a projection that succeeded. + */ + getIncompletenessDiagnostics(): ResolvedSecretIncompletenessDiagnostics | undefined { + if (!this.isPermanentlyIncomplete()) return undefined + return { + reasons: [...this.incompletenessReasons], + incompleteInputPathCount: this.incompleteInputPaths.size, + activeEntryCount: this.activeEntries.size, + ...(this.scope?.workspaceId ? { scopeWorkspaceId: this.scope.workspaceId } : {}), + } + } + + /** Retains a reason for later refusal reporting; the reason type bounds the set at its size. */ + private recordIncompletenessReason(reason: ResolvedSecretIncompletenessReason): void { + this.incompletenessReasons.add(reason) + } + + /** + * Carries a source registry's reasons into a fork or merge target, so a refusal downstream still + * names the guard that originally tripped rather than only the propagation that reached it. + */ + private inheritIncompletenessReasonsFrom(source: ResolvedSecretTraceRegistry): void { + for (const reason of source.incompletenessReasons) this.recordIncompletenessReason(reason) + } + isPermanentlyIncomplete(): boolean { return !this.complete || this.incompleteInputPaths.size > 0 } - markIncomplete(reason: ResolvedSecretIncompletenessReason = 'unspecified'): void { + markIncomplete( + reason: ResolvedSecretIncompletenessReason = 'unspecified', + source?: ResolvedSecretTraceRegistry + ): void { + if (source) this.inheritIncompletenessReasonsFrom(source) + this.recordIncompletenessReason(reason) if (!this.complete) return this.complete = false this.modelEgressRevision += 1 @@ -1743,6 +1797,7 @@ export class ResolvedSecretTraceRegistry { this.markIncomplete(reason) return } + this.recordIncompletenessReason(reason) const key = inputPathKey(path) if (this.incompleteInputPaths.has(key)) return this.incompleteInputPaths.set(key, [...path]) @@ -1763,10 +1818,13 @@ export class ResolvedSecretTraceRegistry { target: ResolvedSecretTraceRegistry, roots?: readonly ResolvedSecretInputPath[] ): void { + let copied = false for (const [key, path] of this.incompleteInputPaths) { if (roots && !roots.some((root) => inputPathsOverlap(path, root))) continue target.incompleteInputPaths.set(key, [...path]) + copied = true } + if (copied) target.inheritIncompletenessReasonsFrom(this) } private addActiveEntry(entry: ActiveSecretEntry, options: { propagated?: boolean } = {}): void { diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index dbf25fdaf02..23810f6ef81 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -31,7 +31,11 @@ import { import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control' import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous' import { buildConnectorProviders } from '@/lib/auth/connectors/providers' -import { getRequestedSignInProviderId, isSignInProviderAllowed } from '@/lib/auth/constants' +import { + applyRegistrationGate, + getRequestedSignInProviderId, + isSignInProviderAllowed, +} from '@/lib/auth/constants' import { getSessionCookieCacheVersion } from '@/lib/auth/security-policy' import { clampExpiryForSession } from '@/lib/auth/session-policy' import { guardSubscriptionPlanWrites } from '@/lib/auth/stripe-adapter-guard' @@ -714,34 +718,42 @@ export const auth = betterAuth({ ], }, }, - socialProviders: { - ...(!isGithubAuthDisabled && { - github: { - clientId: env.GITHUB_CLIENT_ID as string, - clientSecret: env.GITHUB_CLIENT_SECRET as string, - scope: ['user:email', 'repo'], - }, - }), - ...(!isGoogleAuthDisabled && { - google: { - clientId: env.GOOGLE_CLIENT_ID as string, - clientSecret: env.GOOGLE_CLIENT_SECRET as string, - scope: [ - 'https://www.googleapis.com/auth/userinfo.email', - 'https://www.googleapis.com/auth/userinfo.profile', - ], - }, - }), - ...(!isMicrosoftAuthDisabled && - env.MICROSOFT_CLIENT_ID && - env.MICROSOFT_CLIENT_SECRET && { - microsoft: { - clientId: env.MICROSOFT_CLIENT_ID, - clientSecret: env.MICROSOFT_CLIENT_SECRET, - scope: ['openid', 'profile', 'email'], + /** + * SSO is deliberately outside the registration gate: it runs on + * `/sign-in/sso` against admin-configured, domain-verified providers, which + * is its own allowlist. + */ + socialProviders: applyRegistrationGate( + { + ...(!isGithubAuthDisabled && { + github: { + clientId: env.GITHUB_CLIENT_ID as string, + clientSecret: env.GITHUB_CLIENT_SECRET as string, + scope: ['user:email', 'repo'], }, }), - }, + ...(!isGoogleAuthDisabled && { + google: { + clientId: env.GOOGLE_CLIENT_ID as string, + clientSecret: env.GOOGLE_CLIENT_SECRET as string, + scope: [ + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + ], + }, + }), + ...(!isMicrosoftAuthDisabled && + env.MICROSOFT_CLIENT_ID && + env.MICROSOFT_CLIENT_SECRET && { + microsoft: { + clientId: env.MICROSOFT_CLIENT_ID, + clientSecret: env.MICROSOFT_CLIENT_SECRET, + scope: ['openid', 'profile', 'email'], + }, + }), + }, + isRegistrationDisabled + ), emailVerification: { autoSignInAfterVerification: true, afterEmailVerification: async (user) => { diff --git a/apps/sim/lib/auth/constants.test.ts b/apps/sim/lib/auth/constants.test.ts index 4f37d67e031..4aa56edd691 100644 --- a/apps/sim/lib/auth/constants.test.ts +++ b/apps/sim/lib/auth/constants.test.ts @@ -3,6 +3,7 @@ */ import { describe, expect, it } from 'vitest' import { + applyRegistrationGate, getRequestedSignInProviderId, isSignInProviderAllowed, SIGN_IN_PROVIDER_IDS, @@ -84,3 +85,52 @@ describe('getRequestedSignInProviderId', () => { expect(getRequestedSignInProviderId('/sign-in/oauth2', null)).toBeUndefined() }) }) + +describe('registration gate', () => { + const providers = { + google: { clientId: 'g', clientSecret: 'gs', scope: ['email'] }, + github: { clientId: 'h', clientSecret: 'hs' }, + } + + it('leaves the provider map untouched when registration is enabled', () => { + const gated = applyRegistrationGate(providers, false) + + expect(gated).toBe(providers) + expect(gated.google).not.toHaveProperty('disableSignUp') + }) + + it('blocks account creation on every provider when registration is disabled', () => { + const gated = applyRegistrationGate(providers, true) + + for (const config of Object.values(gated)) { + expect(config.disableSignUp).toBe(true) + } + }) + + /** + * The id-token branch of `/sign-in/social` reads a top-level + * `provider.disableSignUp` that Better Auth never hoists from config, so + * `disableSignUp` alone leaves that entrance open. + */ + it('also closes the id-token sign-in entrance', () => { + const gated = applyRegistrationGate(providers, true) + + for (const config of Object.values(gated)) { + expect(config.disableIdTokenSignIn).toBe(true) + } + }) + + it('preserves each provider credential and its keys', () => { + const gated = applyRegistrationGate(providers, true) + + expect(Object.keys(gated)).toEqual(['google', 'github']) + expect(gated.google).toMatchObject({ clientId: 'g', clientSecret: 'gs', scope: ['email'] }) + }) + + it('gates a provider added later without it opting in', () => { + const gated = applyRegistrationGate({ ...providers, someFutureIdp: { clientId: 'f' } }, true) + + expect(gated.someFutureIdp.disableSignUp).toBe(true) + expect(gated.someFutureIdp.disableIdTokenSignIn).toBe(true) + }) +}) diff --git a/apps/sim/lib/auth/constants.ts b/apps/sim/lib/auth/constants.ts index 90972142791..6b78258a4bc 100644 --- a/apps/sim/lib/auth/constants.ts +++ b/apps/sim/lib/auth/constants.ts @@ -52,3 +52,57 @@ export function getRequestedSignInProviderId( if (path === '/sign-in/oauth2') return body?.providerId return undefined } + +/** + * A social provider config, narrowed to the Better Auth options that turn off + * account creation. The index signature carries the rest of the config + * (`clientId`, `scope`, …) untyped — only the two gate keys matter here, and + * typing them catches a rename in a Better Auth upgrade. + */ +interface RegistrationGate { + disableSignUp?: boolean + disableIdTokenSignIn?: boolean + [option: string]: unknown +} + +/** + * Stamps DISABLE_REGISTRATION onto every social provider config. + * + * The `/sign-up*` gate cannot see OAuth account creation, which happens on + * `/sign-in/social` and its callback — without this, a registration-disabled + * deployment still mints accounts for any unknown Google/GitHub/Microsoft + * identity. Stamping the whole map rather than each provider literal means a + * provider added later inherits the gate instead of silently reopening the + * hole; that is the entire point of doing this here rather than inline. + * + * Both keys are required because Better Auth resolves the gate differently per + * entrance. The redirect callback reads `provider.options.disableSignUp`, but + * the id-token branch of `/sign-in/social` reads a **top-level** + * `provider.disableSignUp` that is never hoisted from config, so `disableSignUp` + * alone leaves that entrance open. `disableIdTokenSignIn` closes it by failing + * `verifyIdToken` before any user lookup. Sim only ever uses the redirect flow, + * so disabling the id-token path costs nothing today. + * + * Existing users are unaffected on both paths — the gate only rejects when no + * account matches the verified identity. + * + * Returns the map untouched when the flag is off, so a registration-enabled + * deployment hands Better Auth the exact object it had before. Better Auth also + * accepts a lazily-evaluated (function) provider config, which this could not + * stamp — spreading a function drops its credentials — but {@link + * RegistrationGate}'s index signature makes that a compile error, so it cannot + * reach here ungated. + */ +export function applyRegistrationGate>( + providers: T, + registrationDisabled: boolean +): T { + if (!registrationDisabled) return providers + + const gated: Record = {} + for (const [id, config] of Object.entries(providers)) { + gated[id] = { ...config, disableSignUp: true, disableIdTokenSignIn: true } + } + /** The spread widens past what TypeScript can prove; the keys are unchanged. */ + return gated as T +} diff --git a/apps/sim/lib/billing/core/limit-notifications.test.ts b/apps/sim/lib/billing/core/limit-notifications.test.ts index 1d00608d855..dca72db39b7 100644 --- a/apps/sim/lib/billing/core/limit-notifications.test.ts +++ b/apps/sim/lib/billing/core/limit-notifications.test.ts @@ -26,7 +26,7 @@ vi.mock('@/lib/messaging/email/mailer', () => ({ sendEmail: sendEmailSpy })) vi.mock('@/lib/messaging/email/unsubscribe', () => ({ getEmailPreferences: getEmailPreferencesMock, })) -vi.mock('@/components/emails/render', () => ({ +vi.mock('@/components/emails', () => ({ renderLimitThresholdEmail: renderMock, getLimitEmailSubject: subjectMock, })) @@ -72,6 +72,9 @@ describe('maybeSendLimitThresholdEmail', () => { expect(sendEmailSpy).toHaveBeenCalledTimes(1) expect(renderMock).toHaveBeenCalledWith(expect.objectContaining({ kind: 'warning' })) expect(subjectMock).toHaveBeenCalledWith('storage', 'warning') + // Pins the subject to the shared helper's return, so a sender that builds + // its own string — or a mock aimed at the wrong module path — fails here. + expect(sendEmailSpy).toHaveBeenCalledWith(expect.objectContaining({ subject: 'Subject' })) }) it('sends a reached email at/over 100%', async () => { diff --git a/apps/sim/lib/billing/core/limit-notifications.ts b/apps/sim/lib/billing/core/limit-notifications.ts index 3b0b7f753a3..2df581f402a 100644 --- a/apps/sim/lib/billing/core/limit-notifications.ts +++ b/apps/sim/lib/billing/core/limit-notifications.ts @@ -3,7 +3,7 @@ import { member, organization, settings, user, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, eq, sql } from 'drizzle-orm' -import { getLimitEmailSubject, renderLimitThresholdEmail } from '@/components/emails/render' +import { getLimitEmailSubject, renderLimitThresholdEmail } from '@/components/emails' import type { HighestPrioritySubscription } from '@/lib/billing/core/plan' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import type { BillingEntity } from '@/lib/billing/core/usage-log' diff --git a/apps/sim/lib/billing/core/subscription.ts b/apps/sim/lib/billing/core/subscription.ts index e4b9fa010d2..44345553e4d 100644 --- a/apps/sim/lib/billing/core/subscription.ts +++ b/apps/sim/lib/billing/core/subscription.ts @@ -731,21 +731,24 @@ export async function sendPlanWelcomeEmail(subscription: any): Promise { .limit(1) if (users.length > 0 && users[0].email) { - const { getEmailSubject, renderPlanWelcomeEmail } = await import('@/components/emails') + const { getPlanWelcomeSubject, renderPlanWelcomeEmail } = await import( + '@/components/emails' + ) const { sendEmail } = await import('@/lib/messaging/email/mailer') const baseUrl = getBaseUrl() const { getDisplayPlanName } = await import('@/lib/billing/plan-helpers') + const displayName = getDisplayPlanName(subPlan) + const html = await renderPlanWelcomeEmail({ - planName: getDisplayPlanName(subPlan), + planName: displayName, userName: users[0].name || undefined, loginLink: `${baseUrl}/login`, }) - const displayName = getDisplayPlanName(subPlan) await sendEmail({ to: users[0].email, - subject: `Your ${displayName} plan is now active on ${(await import('@/ee/whitelabeling')).getBrandConfig().name}`, + subject: getPlanWelcomeSubject(displayName), html, emailType: 'updates', }) diff --git a/apps/sim/lib/billing/webhooks/invoices.test.ts b/apps/sim/lib/billing/webhooks/invoices.test.ts index cdd2d96088c..61368c8050a 100644 --- a/apps/sim/lib/billing/webhooks/invoices.test.ts +++ b/apps/sim/lib/billing/webhooks/invoices.test.ts @@ -18,9 +18,9 @@ const { mockBlockOrgMembers, mockUnblockOrgMembers } = vi.hoisted(() => ({ })) vi.mock('@/components/emails', () => ({ - PaymentFailedEmail: vi.fn(), getEmailSubject: vi.fn(), renderCreditPurchaseEmail: vi.fn(), + renderPaymentFailedEmail: vi.fn(), })) vi.mock('@/lib/billing/core/billing', () => ({ @@ -93,10 +93,6 @@ vi.mock('@/lib/messaging/email/validation', () => ({ quickValidateEmail: vi.fn(() => ({ isValid: true })), })) -vi.mock('@react-email/render', () => ({ - render: vi.fn(), -})) - import { handleInvoicePaymentFailed, handleInvoicePaymentSucceeded, diff --git a/apps/sim/lib/billing/webhooks/invoices.ts b/apps/sim/lib/billing/webhooks/invoices.ts index 52e8b423a3b..a89383ccab0 100644 --- a/apps/sim/lib/billing/webhooks/invoices.ts +++ b/apps/sim/lib/billing/webhooks/invoices.ts @@ -1,4 +1,3 @@ -import { render } from '@react-email/render' import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { @@ -12,7 +11,11 @@ import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm' import type Stripe from 'stripe' -import { getEmailSubject, PaymentFailedEmail, renderCreditPurchaseEmail } from '@/components/emails' +import { + getEmailSubject, + renderCreditPurchaseEmail, + renderPaymentFailedEmail, +} from '@/components/emails' import { BILLING_LOCK_TIMEOUT_MS } from '@/lib/billing/constants' import { calculateSubscriptionOverage, isSubscriptionOrgScoped } from '@/lib/billing/core/billing' import { @@ -353,22 +356,19 @@ async function sendPaymentFailureEmails( // Send emails to all affected users for (const userToNotify of usersToNotify) { try { - const emailHtml = await render( - PaymentFailedEmail({ - userName: userToNotify.name || undefined, - amountDue, - lastFourDigits, - billingPortalUrl, - failureReason, - sentDate: new Date(), - }) - ) + const emailHtml = await renderPaymentFailedEmail({ + userName: userToNotify.name || undefined, + amountDue, + lastFourDigits, + billingPortalUrl, + failureReason, + }) const { from } = getPersonalEmailFrom() const replyTo = getHelpEmailAddress() await sendEmail({ to: userToNotify.email, - subject: 'Payment Failed - Action Required', + subject: getEmailSubject('payment-failed'), html: emailHtml, from, replyTo, diff --git a/apps/sim/lib/content/og-image.test.ts b/apps/sim/lib/content/og-image.test.ts new file mode 100644 index 00000000000..998cf3ff967 --- /dev/null +++ b/apps/sim/lib/content/og-image.test.ts @@ -0,0 +1,55 @@ +/** + * @vitest-environment node + */ +import fs from 'fs' +import path from 'path' +import matter from 'gray-matter' +import sharp from 'sharp' +import { describe, expect, it } from 'vitest' + +/** + * Guards the content invariant behind `ogImageWidth`/`ogImageHeight` in + * `registry-factory`: every local `ogImage` must exist and expose intrinsic + * dimensions, or the SEO builders silently fall back to a 1200x630 default that + * misdescribes the real asset. + * + * It also pins the format to one the social crawlers actually accept. SVG is the + * trap worth naming: it renders fine in the browser and `sharp` reports + * dimensions for it, so a dimension check alone would pass while Open Graph + * previews silently break. + */ +const CRAWLER_SAFE_FORMATS = ['jpeg', 'png', 'webp', 'gif'] + +function collectOgImages(): { slug: string; ogImage: string }[] { + const entries: { slug: string; ogImage: string }[] = [] + for (const dir of ['content/blog', 'content/library']) { + if (!fs.existsSync(dir)) continue + for (const slug of fs.readdirSync(dir)) { + const mdxPath = path.join(dir, slug, 'index.mdx') + if (!fs.existsSync(mdxPath)) continue + const { data } = matter(fs.readFileSync(mdxPath, 'utf-8')) + if (typeof data.ogImage === 'string' && !data.ogImage.startsWith('http')) { + entries.push({ slug, ogImage: data.ogImage }) + } + } + } + return entries +} + +describe('content OG images', () => { + const entries = collectOgImages() + + it('finds local OG images to check', () => { + expect(entries.length).toBeGreaterThan(0) + }) + + it.each(entries)('$slug resolves readable dimensions for $ogImage', async ({ ogImage }) => { + const file = path.join('public', ogImage) + expect(fs.existsSync(file), `${file} does not exist`).toBe(true) + + const { width, height, format } = await sharp(fs.readFileSync(file)).metadata() + expect(width, `${file} has no readable width`).toBeGreaterThan(0) + expect(height, `${file} has no readable height`).toBeGreaterThan(0) + expect(CRAWLER_SAFE_FORMATS, `${file} is a ${format}, which crawlers reject`).toContain(format) + }) +}) diff --git a/apps/sim/lib/content/registry-factory.ts b/apps/sim/lib/content/registry-factory.ts index ca06e5e6b13..8df6fceba6f 100644 --- a/apps/sim/lib/content/registry-factory.ts +++ b/apps/sim/lib/content/registry-factory.ts @@ -1,17 +1,20 @@ import fs from 'fs/promises' import path from 'path' import { cache } from 'react' +import { createLogger } from '@sim/logger' import matter from 'gray-matter' -import { imageSize } from 'image-size' import { compileMDX } from 'next-mdx-remote/rsc' import rehypeAutolinkHeadings from 'rehype-autolink-headings' import rehypeSlug from 'rehype-slug' import remarkGfm from 'remark-gfm' +import sharp from 'sharp' import { mdxComponents } from '@/lib/content/mdx' import type { Author, ContentMeta, ContentPost, TagWithCount } from '@/lib/content/schema' import { AuthorSchema, ContentFrontmatterSchema } from '@/lib/content/schema' import { byDateDesc, ensureContentDirs, toIsoDate } from '@/lib/content/utils' +const logger = createLogger('ContentRegistry') + /** Loads a post's custom MDX component overrides, keyed by slug. */ export type ContentComponentLoaders = Record< string, @@ -95,6 +98,10 @@ export function createContentRegistry(config: ContentRegistryConfig): ContentReg * SEO builders can declare accurate `og:image` and JSON-LD sizes. Returns * null for remote URLs or unreadable files, in which case the builders fall * back to the 1200x630 OG default. + * + * Uses `sharp`, which only parses headers for `metadata()`. It replaced the + * `image-size` package, archived upstream with unpatched DoS advisories in + * its ICNS/JXL/HEIF parsers (GHSA-w3rx-r6r6-pgpr, GHSA-5p2g-fcmc-qvqq). */ async function readOgImageDimensions( ogImage: string @@ -102,8 +109,14 @@ export function createContentRegistry(config: ContentRegistryConfig): ContentReg if (ogImage.startsWith('http')) return null try { const buffer = await fs.readFile(path.join(process.cwd(), 'public', ogImage)) - const { width, height } = imageSize(buffer) - return width && height ? { width, height } : null + const { width, height } = await sharp(buffer).metadata() + if (!width || !height) { + logger.warn('OG image has no readable dimensions; falling back to the OG default', { + ogImage, + }) + return null + } + return { width, height } } catch { return null } diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index b17dc94f4e2..dfa0ca2ea91 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -69,6 +69,7 @@ import { isHosted, } from '@/lib/core/config/env-flags' import { filterModelSafeWorkspaceFileAttachments } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const logger = createLogger('CopilotLifecycle') @@ -81,9 +82,11 @@ const MOTHERSHIP_CODE_TOOL_ROUTES = new Set([ '/api/mothership/execute', ]) +const COPILOT_MODEL_CONTENT_PROJECTION_ERROR = 'Copilot model input could not be safely projected' + class CopilotModelContentProjectionError extends Error { constructor() { - super('Copilot model input could not be safely projected') + super(COPILOT_MODEL_CONTENT_PROJECTION_ERROR) this.name = 'CopilotModelContentProjectionError' } } @@ -96,7 +99,14 @@ async function omitUnsafeInitialCopilotAttachments( for (const key of ['attachments', 'fileAttachments'] as const) { if (!Object.hasOwn(projected, key)) continue const attachments = projected[key] - if (!Array.isArray(attachments)) throw new CopilotModelContentProjectionError() + if (!Array.isArray(attachments)) { + refuseResolvedSecretProjection({ + site: 'copilot.initialAttachmentsShape', + message: COPILOT_MODEL_CONTENT_PROJECTION_ERROR, + inputPath: key, + createError: () => new CopilotModelContentProjectionError(), + }) + } let safeAttachments: unknown[] try { @@ -106,7 +116,12 @@ async function omitUnsafeInitialCopilotAttachments( attachmentCount: attachments.length, error: toError(error).message, }) - throw new CopilotModelContentProjectionError() + refuseResolvedSecretProjection({ + site: 'copilot.initialAttachmentsProvenance', + message: COPILOT_MODEL_CONTENT_PROJECTION_ERROR, + inputPath: key, + createError: () => new CopilotModelContentProjectionError(), + }) } if (safeAttachments.length === attachments.length) continue diff --git a/apps/sim/lib/guardrails/validate_hallucination.ts b/apps/sim/lib/guardrails/validate_hallucination.ts index a3396a37ce9..7185236baaf 100644 --- a/apps/sim/lib/guardrails/validate_hallucination.ts +++ b/apps/sim/lib/guardrails/validate_hallucination.ts @@ -22,6 +22,7 @@ import { } from '@/lib/execution/private-tool-metadata' import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' +import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { executeProviderRequest } from '@/providers' import { isAbortError } from '@/providers/streaming-tool-loop-shared' @@ -388,7 +389,12 @@ export async function validateHallucination( !Array.isArray(contextProjection.value) || !contextProjection.value.every((value) => typeof value === 'string') ) { - throw new Error('Hallucination model input could not be safely projected') + refuseResolvedSecretProjection({ + site: 'guardrails.hallucinationModelInput', + message: 'Hallucination model input could not be safely projected', + registry: inputRegistry, + inputPath: 'input', + }) } const providerRegistry = inputRegistry diff --git a/apps/sim/lib/knowledge/model-input-provenance.ts b/apps/sim/lib/knowledge/model-input-provenance.ts index fb244dc2ca9..af4ea906476 100644 --- a/apps/sim/lib/knowledge/model-input-provenance.ts +++ b/apps/sim/lib/knowledge/model-input-provenance.ts @@ -2,6 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context' import { inspectModelInputProvenanceRequest } from '@/lib/execution/model-input-provenance' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' +import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import { isResolvedSecretTraceProvenanceV1, ResolvedSecretTraceRegistry, @@ -91,8 +92,13 @@ export function runWithKnowledgeModelInputProvenance( /** Rejects opaque bytes/URLs that cannot be selectively projected before an external model call. */ export function assertKnowledgeOpaqueModelInputSafe(): void { - if (knowledgeModelInputContext.getStore()?.opaqueInputSafe === false) { - throw new Error(MODEL_INPUT_PROJECTION_ERROR) + const context = knowledgeModelInputContext.getStore() + if (context?.opaqueInputSafe === false) { + refuseResolvedSecretProjection({ + site: 'knowledge.opaqueModelInputSafety', + message: MODEL_INPUT_PROJECTION_ERROR, + registry: context.registry, + }) } } @@ -100,7 +106,11 @@ export function assertKnowledgeOpaqueModelInputSafe(): void { export function getKnowledgeOpaqueModelInputRegistry(): ResolvedSecretTraceRegistry { const context = knowledgeModelInputContext.getStore() if (!context?.opaqueInputSafe) { - throw new Error(MODEL_INPUT_PROJECTION_ERROR) + refuseResolvedSecretProjection({ + site: 'knowledge.opaqueModelInputRegistry', + message: MODEL_INPUT_PROJECTION_ERROR, + registry: context?.registry, + }) } return context.registry ?? new ResolvedSecretTraceRegistry() } @@ -112,7 +122,11 @@ export function projectKnowledgeModelInput(value: string): string { const projection = projectResolvedSecretModelContent(value, registry) if (!projection.safe || typeof projection.value !== 'string') { - throw new Error(MODEL_INPUT_PROJECTION_ERROR) + refuseResolvedSecretProjection({ + site: 'knowledge.modelInput', + message: MODEL_INPUT_PROJECTION_ERROR, + registry, + }) } return projection.value } @@ -128,7 +142,11 @@ export function projectKnowledgeModelInputs(values: readonly string[]): string[] !Array.isArray(projection.value) || !projection.value.every((value) => typeof value === 'string') ) { - throw new Error(MODEL_INPUT_PROJECTION_ERROR) + refuseResolvedSecretProjection({ + site: 'knowledge.modelInputs', + message: MODEL_INPUT_PROJECTION_ERROR, + registry, + }) } return projection.value } diff --git a/apps/sim/lib/mothership/inbox/response.ts b/apps/sim/lib/mothership/inbox/response.ts index 9f76951cf8c..e4c0561c8ea 100644 --- a/apps/sim/lib/mothership/inbox/response.ts +++ b/apps/sim/lib/mothership/inbox/response.ts @@ -1,12 +1,11 @@ -import { type ComponentType, type CSSProperties, createElement, type ReactNode } from 'react' -import { Body, Head, Html, Link, Markdown, Section, Text } from '@react-email/components' -import { render } from '@react-email/render' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { renderInboxErrorEmail, renderInboxResponseEmail } from '@/components/emails' import { getBaseUrl } from '@/lib/core/utils/urls' import * as agentmail from '@/lib/mothership/inbox/agentmail-client' import { replaceUntilStable } from '@/lib/mothership/inbox/format' import type { InboxTask } from '@/lib/mothership/inbox/types' +import { getBrandConfig } from '@/ee/whitelabeling' const logger = createLogger('InboxResponse') @@ -35,13 +34,18 @@ export async function sendInboxResponse( ? `${getBaseUrl()}/workspace/${ctx.workspaceId}/chat/${inboxTask.chatId}` : `${getBaseUrl()}/workspace/${ctx.workspaceId}/home` + const brandName = getBrandConfig().name + const text = result.success - ? `${result.content}\n\n[View full conversation](${chatUrl})\n\nBest,\nMothership` - : `I wasn't able to complete this task.\n\nError: ${result.error || 'Unknown error'}\n\n[View details](${chatUrl})\n\nBest,\nMothership` + ? `${result.content}\n\n[View full conversation](${chatUrl})\n\nBest,\n${brandName}` + : `I wasn't able to complete this task.\n\nError: ${result.error || 'Unknown error'}\n\n[View details](${chatUrl})\n\nBest,\n${brandName}` const html = result.success - ? await renderEmailHtml(result.content, chatUrl) - : await renderErrorHtml(result.error || 'Unknown error', chatUrl) + ? await renderInboxResponseEmail({ + markdown: preserveSoftBreaks(stripRawHtml(result.content)), + chatUrl, + }) + : await renderInboxErrorEmail({ error: result.error || 'Unknown error', chatUrl }) try { const response = await agentmail.replyToMessage( @@ -61,200 +65,6 @@ export async function sendInboxResponse( } } -const FONT_FAMILY = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, sans-serif" -const CODE_FONT_FAMILY = "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace" - -const emailStyles = { - body: { - fontFamily: FONT_FAMILY, - fontSize: '15px', - lineHeight: '25px', - color: '#1a1a1a', - fontWeight: 400, - }, - content: { - margin: 0, - }, - markdownContainer: { - margin: 0, - }, - signature: { - color: '#525252', - marginTop: '32px', - fontSize: '14px', - }, - signatureText: { - color: '#525252', - margin: '0 0 16px 0', - fontSize: '14px', - lineHeight: '25px', - fontFamily: FONT_FAMILY, - }, - signatureLink: { - color: '#1a1a1a', - textDecoration: 'underline', - textDecorationStyle: 'dashed', - textUnderlineOffset: '2px', - }, -} satisfies Record - -const markdownStyles = { - p: { - margin: '0 0 16px 0', - fontSize: '15px', - lineHeight: '25px', - color: '#1a1a1a', - fontFamily: FONT_FAMILY, - fontWeight: 400, - }, - h1: { - fontWeight: 600, - color: '#1a1a1a', - margin: '24px 0 12px 0', - fontSize: '24px', - lineHeight: '32px', - fontFamily: FONT_FAMILY, - }, - h2: { - fontWeight: 600, - color: '#1a1a1a', - margin: '24px 0 12px 0', - fontSize: '20px', - lineHeight: '28px', - fontFamily: FONT_FAMILY, - }, - h3: { - fontWeight: 600, - color: '#1a1a1a', - margin: '24px 0 12px 0', - fontSize: '16px', - lineHeight: '24px', - fontFamily: FONT_FAMILY, - }, - h4: { - fontWeight: 600, - color: '#1a1a1a', - margin: '24px 0 12px 0', - fontSize: '15px', - lineHeight: '25px', - fontFamily: FONT_FAMILY, - }, - strong: { - fontWeight: 600, - color: '#1a1a1a', - }, - codeInline: { - backgroundColor: '#f3f3f3', - padding: '2px 6px', - borderRadius: '4px', - fontFamily: CODE_FONT_FAMILY, - fontSize: '13px', - color: '#1a1a1a', - }, - codeBlock: { - backgroundColor: '#f3f3f3', - padding: '16px', - borderRadius: '8px', - border: '1px solid #ededed', - overflowX: 'auto', - margin: '24px 0', - fontFamily: CODE_FONT_FAMILY, - fontSize: '13px', - lineHeight: '21px', - color: '#1a1a1a', - }, - table: { - borderCollapse: 'collapse', - margin: '16px 0', - }, - th: { - border: '1px solid #ededed', - padding: '8px 12px', - textAlign: 'left', - fontSize: '14px', - backgroundColor: '#f5f5f5', - fontWeight: 600, - }, - td: { - border: '1px solid #ededed', - padding: '8px 12px', - textAlign: 'left', - fontSize: '14px', - }, - blockQuote: { - borderLeft: '4px solid #e0e0e0', - margin: '16px 0', - padding: '4px 16px', - color: '#525252', - fontStyle: 'italic', - }, - a: { - color: '#2563eb', - textDecoration: 'underline', - textDecorationStyle: 'dashed', - textUnderlineOffset: '2px', - }, - ul: { - margin: '16px 0', - paddingLeft: '24px', - }, - ol: { - margin: '16px 0', - paddingLeft: '24px', - }, - li: { - margin: '4px 0', - }, - hr: { - border: 'none', - borderTop: '1px solid #ededed', - margin: '24px 0', - }, -} satisfies Record - -interface InboxResponseEmailProps { - children?: ReactNode - chatUrl: string - linkLabel: string -} - -interface EmailMarkdownProps { - children?: string - markdownContainerStyles?: CSSProperties - markdownCustomStyles?: Record -} - -const EmailMarkdown = Markdown as ComponentType - -function InboxResponseEmail({ children, chatUrl, linkLabel }: InboxResponseEmailProps) { - return createElement( - Html, - { lang: 'en', dir: 'ltr' }, - createElement(Head), - createElement( - Body, - { style: emailStyles.body }, - createElement(Section, { style: emailStyles.content }, children), - createElement( - Section, - { style: emailStyles.signature }, - createElement( - Text, - { style: emailStyles.signatureText }, - createElement(Link, { href: chatUrl, style: emailStyles.signatureLink }, linkLabel) - ), - createElement( - Text, - { style: emailStyles.signatureText }, - 'Best,', - createElement('br'), - 'Sim' - ) - ) - ) - ) -} - function stripRawHtml(text: string): string { return text .split(/(```[\s\S]*?```)/g) @@ -270,48 +80,3 @@ function preserveSoftBreaks(text: string): string { .map((segment, i) => (i % 2 === 0 ? segment.replace(/([^\n])\n(?=[^\n])/g, '$1 \n') : segment)) .join('') } - -function stripUnsafeUrls(html: string): string { - return html.replace(/href\s*=\s*(['"])(?:javascript|vbscript|data):.*?\1/gi, 'href="#"') -} - -async function renderEmailHtml(markdown: string, chatUrl: string): Promise { - const safeMarkdown = preserveSoftBreaks(stripRawHtml(markdown)) - const html = await render( - createElement( - InboxResponseEmail, - { chatUrl, linkLabel: 'View full conversation' }, - createElement( - EmailMarkdown, - { - markdownContainerStyles: emailStyles.markdownContainer, - markdownCustomStyles: markdownStyles, - }, - safeMarkdown - ) - ) - ) - - return stripUnsafeUrls(html) -} - -async function renderErrorHtml(error: string, chatUrl: string): Promise { - const html = await render( - createElement( - InboxResponseEmail, - { chatUrl, linkLabel: 'View details' }, - createElement( - Text, - { key: 'message', style: markdownStyles.p }, - "I wasn't able to complete this task." - ), - createElement( - Text, - { key: 'error', style: { ...markdownStyles.p, color: '#6b7280' } }, - `Error: ${error}` - ) - ) - ) - - return stripUnsafeUrls(html) -} diff --git a/apps/sim/package.json b/apps/sim/package.json index b59834d953a..7b0e95742e6 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -174,7 +174,6 @@ "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "idb-keyval": "6.2.2", - "image-size": "2.0.2", "imapflow": "1.2.4", "input-otp": "^1.4.2", "ioredis": "^5.6.0", diff --git a/apps/sim/tools/request-transport.ts b/apps/sim/tools/request-transport.ts index 64d1f3c20ab..41bca3e8e99 100644 --- a/apps/sim/tools/request-transport.ts +++ b/apps/sim/tools/request-transport.ts @@ -7,6 +7,7 @@ import { createPrivateSecretProvenanceRequestMetadata, markModelInputProjected, } from '@/lib/execution/model-input-provenance' +import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import type { ToolConfig } from '@/tools/types' @@ -117,7 +118,11 @@ export function projectToolModelInputParams( return patchedParams } catch { - throw new Error(MODEL_INPUT_PROJECTION_ERROR_MESSAGE) + refuseResolvedSecretProjection({ + site: 'tools.requestTransportModelInput', + message: MODEL_INPUT_PROJECTION_ERROR_MESSAGE, + registry, + }) } } diff --git a/bun.lock b/bun.lock index 1e30a508d8c..3f93d781422 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "simstudio", @@ -276,7 +277,6 @@ "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "idb-keyval": "6.2.2", - "image-size": "2.0.2", "imapflow": "1.2.4", "input-otp": "^1.4.2", "ioredis": "^5.6.0", @@ -3121,7 +3121,7 @@ "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "image-size": ["image-size@2.0.2", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w=="], + "image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="], "imapflow": ["imapflow@1.2.4", "", { "dependencies": { "@zone-eu/mailsplit": "5.4.8", "encoding-japanese": "2.2.0", "iconv-lite": "0.7.1", "libbase64": "1.3.0", "libmime": "5.3.7", "libqp": "2.1.1", "nodemailer": "7.0.12", "pino": "10.1.0", "socks": "2.8.7" } }, "sha512-X/eRQeje33uZycfopjwoQKKbya+bBIaqpviOFxhPOD24DXU2hMfXwYe9e8j1+ADwFVgTvKq4G2/ljjZK3Y8mvg=="], @@ -5155,8 +5155,6 @@ "pptxgenjs/@types/node": ["@types/node@22.19.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA=="], - "pptxgenjs/image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="], - "protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "proxy-addr/ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],