From 3bf0ed047e7718e2a0a94d719521641db871a752 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 27 Mar 2026 00:16:15 +0000 Subject: [PATCH 01/23] feat: add secret-aware fetch gateway Co-authored-by: me --- packages/worker/client/routes/account.tsx | 344 ++++++++++++++++-- packages/worker/client/routes/index.tsx | 1 + .../migrations/0009-secret-allowed-hosts.sql | 2 + packages/worker/src/app/authenticated-user.ts | 2 +- .../src/app/handlers/account-secrets.ts | 307 ++++++++++++++++ packages/worker/src/app/handlers/account.ts | 6 +- packages/worker/src/app/router.ts | 7 + packages/worker/src/app/routes.ts | 3 + packages/worker/src/index.ts | 3 +- .../mcp/capabilities/meta/meta-run-skill.ts | 2 + .../src/mcp/capabilities/secrets/domain.ts | 2 +- .../mcp/capabilities/secrets/secret-get.ts | 2 +- .../mcp/capabilities/secrets/secret-list.ts | 3 +- .../mcp/capabilities/secrets/secret-update.ts | 3 +- .../src/mcp/capabilities/secrets/shared.ts | 1 + .../mcp/capabilities/unified-search.test.ts | 2 +- .../src/mcp/capabilities/unified-search.ts | 4 +- packages/worker/src/mcp/executor.ts | 36 +- packages/worker/src/mcp/fetch-gateway.ts | 159 ++++++++ packages/worker/src/mcp/generated-ui-api.ts | 89 ++--- packages/worker/src/mcp/index.ts | 10 +- .../worker/src/mcp/mcp-registration-agent.ts | 1 + .../worker/src/mcp/mcp-server-e2e.test.ts | 241 +++++++----- .../worker/src/mcp/run-codemode-registry.ts | 15 +- .../worker/src/mcp/secrets/allowed-hosts.ts | 31 ++ .../worker/src/mcp/secrets/host-approval.ts | 82 +++++ packages/worker/src/mcp/secrets/repo.ts | 17 +- packages/worker/src/mcp/secrets/service.ts | 82 +++++ packages/worker/src/mcp/secrets/types.ts | 2 + packages/worker/src/mcp/tools/execute.ts | 21 +- .../worker/src/mcp/tools/open-generated-ui.ts | 7 +- .../test-support/cloudflare-workers-stub.ts | 12 + 32 files changed, 1283 insertions(+), 216 deletions(-) create mode 100644 packages/worker/migrations/0009-secret-allowed-hosts.sql create mode 100644 packages/worker/src/app/handlers/account-secrets.ts create mode 100644 packages/worker/src/mcp/fetch-gateway.ts create mode 100644 packages/worker/src/mcp/secrets/allowed-hosts.ts create mode 100644 packages/worker/src/mcp/secrets/host-approval.ts diff --git a/packages/worker/client/routes/account.tsx b/packages/worker/client/routes/account.tsx index 4405bb29e5..0e8340c1f3 100644 --- a/packages/worker/client/routes/account.tsx +++ b/packages/worker/client/routes/account.tsx @@ -1,56 +1,166 @@ import { type Handle } from 'remix/component' -import { colors, spacing, typography } from '#client/styles/tokens.ts' +import { navigate } from '#client/client-router.tsx' +import { colors, mq, spacing, typography } from '#client/styles/tokens.ts' -type AccountStatus = 'idle' | 'loading' | 'ready' | 'error' +type AccountStatus = 'loading' | 'ready' | 'error' +type ApprovalAction = 'approve' | 'reject' +type SecretScope = 'session' | 'app' | 'user' + +type SecretListItem = { + name: string + scope: SecretScope + description: string + appId: string | null + appTitle: string | null + allowedHosts: Array + createdAt: string + updatedAt: string + ttlMs: number | null +} + +type ApprovalView = { + token: string + name: string + scope: SecretScope + requestedHost: string + currentAllowedHosts: Array +} + +type AccountSecretsPayload = { + ok: true + email: string + secrets: Array + approval: ApprovalView | null +} + +const accountSecretsApiPath = '/account/secrets.json' + +function getScopeLabel(scope: SecretScope) { + if (scope === 'app') return 'App' + if (scope === 'session') return 'Session' + return 'User' +} + +function formatRelativeTtl(ttlMs: number | null) { + if (ttlMs == null) return 'No expiry' + const totalMinutes = Math.max(1, Math.round(ttlMs / 60_000)) + if (totalMinutes < 60) return `Expires in ${totalMinutes} min` + const totalHours = Math.round(totalMinutes / 60) + if (totalHours < 48) return `Expires in ${totalHours} hr` + const totalDays = Math.round(totalHours / 24) + return `Expires in ${totalDays} day${totalDays === 1 ? '' : 's'}` +} + +async function readJson(response: Response) { + return (await response.json().catch(() => null)) as T | null +} export function AccountRoute(handle: Handle) { let status: AccountStatus = 'loading' let email = '' + let secrets: Array = [] + let approval: ApprovalView | null = null let message: string | null = null + let submittingApprovalAction: ApprovalAction | null = null + let lastLoadedHref = '' - async function loadAccount(signal: AbortSignal) { + async function loadAccountSecrets(signal: AbortSignal) { try { - const response = await fetch('/session', { - headers: { Accept: 'application/json' }, - credentials: 'include', - signal, - }) + const href = + typeof window === 'undefined' ? '/account' : window.location.href + lastLoadedHref = href + const response = await fetch( + `${accountSecretsApiPath}${new URL(href).search}`, + { + headers: { Accept: 'application/json' }, + credentials: 'include', + signal, + }, + ) if (signal.aborted) return - const payload = await response.json().catch(() => null) - const sessionEmail = - response.ok && - payload?.ok && - typeof payload?.session?.email === 'string' - ? payload.session.email.trim() - : '' - if (!sessionEmail) { + if (response.status === 401) { window.location.assign('/login') return } - email = sessionEmail + const payload = await readJson(response) + if (!response.ok || !payload?.ok) { + throw new Error('Unable to load your account secrets.') + } + email = payload.email + secrets = payload.secrets + approval = payload.approval status = 'ready' message = null + submittingApprovalAction = null handle.update() - } catch { + } catch (error) { if (signal.aborted) return status = 'error' - message = 'Unable to load your account.' + message = + error instanceof Error ? error.message : 'Unable to load your account.' + handle.update() + } + } + + async function submitApproval(action: ApprovalAction) { + if (!approval || submittingApprovalAction != null) return + submittingApprovalAction = action + message = null + handle.update() + try { + const response = await fetch(accountSecretsApiPath, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + action, + requestToken: approval.token, + }), + }) + if (response.status === 401) { + window.location.assign('/login') + return + } + const payload = await readJson< + AccountSecretsPayload & { error?: string; ok?: boolean } + >(response) + if (!response.ok || !payload?.ok) { + throw new Error(payload?.error || 'Unable to process approval.') + } + email = payload.email + secrets = payload.secrets + approval = payload.approval + submittingApprovalAction = null + message = + action === 'approve' + ? 'Approved requested host.' + : 'Rejected host approval request.' + navigate('/account') + } catch (error) { + submittingApprovalAction = null + message = + error instanceof Error ? error.message : 'Unable to process approval.' handle.update() } } return () => { - if (status === 'loading') { - handle.queueTask(loadAccount) + const currentHref = + typeof window === 'undefined' ? '/account' : window.location.href + if (status === 'loading' || currentHref !== lastLoadedHref) { + handle.queueTask(loadAccountSecrets) } return (
@@ -62,19 +172,199 @@ export function AccountRoute(handle: Handle) { margin: 0, }} > - {email ? `Welcome, ${email}` : 'Welcome'} + {email ? `${email} secret approvals` : 'Secret approvals'} -

You are signed in to kody.

+

+ Manage which hosts may receive stored secrets. +

+ + {approval ? ( +
+
+

+ Approve host access +

+

+ Allow {approval.requestedHost} to receive secret{' '} + {approval.name} from the {approval.scope} scope. +

+

+ Current allowed hosts:{' '} + {approval.currentAllowedHosts.length > 0 + ? approval.currentAllowedHosts.join(', ') + : 'none'} +

+
+
+ + +
+
+ ) : null} + {status === 'loading' ? ( -

Loading your account…

+

+ Loading secret approvals… +

) : null} {message ? ( -

+

{message}

) : null} + + {status === 'ready' ? ( +
+

+ Saved secrets +

+ {secrets.length === 0 ? ( +

+ No user or app secrets are currently stored. +

+ ) : ( +
    + {secrets.map((secret) => ( +
  • +
    +
    + {secret.name} + + {getScopeLabel(secret.scope)} + {secret.appTitle ? ` - ${secret.appTitle}` : ''} + +
    + + {formatRelativeTtl(secret.ttlMs)} + +
    + {secret.description ? ( +

    + {secret.description} +

    + ) : null} +
    + Allowed hosts + {secret.allowedHosts.length > 0 ? ( +
    + {secret.allowedHosts.map((host) => ( + + {host} + + ))} +
    + ) : ( +

    + No hosts approved yet. +

    + )} +
    +
  • + ))} +
+ )} +
+ ) : null}
) } } + +const primaryButtonCss = { + padding: `${spacing.sm} ${spacing.md}`, + borderRadius: '999px', + border: 'none', + backgroundColor: colors.primary, + color: 'white', + fontWeight: typography.fontWeight.medium, + cursor: 'pointer', + [mq.mobile]: { + width: '100%', + }, +} + +const secondaryButtonCss = { + ...primaryButtonCss, + backgroundColor: 'transparent', + color: colors.text, + border: `1px solid ${colors.border}`, +} diff --git a/packages/worker/client/routes/index.tsx b/packages/worker/client/routes/index.tsx index 4fb7396595..5c95dc824e 100644 --- a/packages/worker/client/routes/index.tsx +++ b/packages/worker/client/routes/index.tsx @@ -13,6 +13,7 @@ export const clientRoutes = { '/chat/:threadId': , '/ui/:id': , '/account': , + '/account/secrets/approve': , '/login': , '/signup': , '/reset-password': , diff --git a/packages/worker/migrations/0009-secret-allowed-hosts.sql b/packages/worker/migrations/0009-secret-allowed-hosts.sql new file mode 100644 index 0000000000..123a498b13 --- /dev/null +++ b/packages/worker/migrations/0009-secret-allowed-hosts.sql @@ -0,0 +1,2 @@ +ALTER TABLE secret_entries +ADD COLUMN allowed_hosts TEXT NOT NULL DEFAULT '[]'; diff --git a/packages/worker/src/app/authenticated-user.ts b/packages/worker/src/app/authenticated-user.ts index 377abbcfad..78fd0518ad 100644 --- a/packages/worker/src/app/authenticated-user.ts +++ b/packages/worker/src/app/authenticated-user.ts @@ -39,7 +39,7 @@ export async function readAuthenticatedAppUser(request: Request, env: Env) { new Set([session.id, emailBasedUserId].filter(Boolean)), ), mcpUser: { - userId: session.id, + userId: emailBasedUserId, email: session.email, displayName: buildDisplayName(session.email), }, diff --git a/packages/worker/src/app/handlers/account-secrets.ts b/packages/worker/src/app/handlers/account-secrets.ts new file mode 100644 index 0000000000..c42bee7167 --- /dev/null +++ b/packages/worker/src/app/handlers/account-secrets.ts @@ -0,0 +1,307 @@ +import { type BuildAction } from 'remix/fetch-router' +import { readAuthenticatedAppUser } from '#app/authenticated-user.ts' +import { redirectToLogin } from '#app/auth-redirect.ts' +import { verifySecretHostApprovalToken } from '#mcp/secrets/host-approval.ts' +import { + listAppSecretsByAppIds, + listSecrets, + setSecretAllowedHosts, +} from '#mcp/secrets/service.ts' +import { type SecretScope } from '#mcp/secrets/types.ts' +import { getUiArtifactByOwnerIds, listUiArtifactsByUserId } from '#mcp/ui-artifacts-repo.ts' +import { type routes } from '#app/routes.ts' + +type AccountSecretListItem = { + name: string + scope: SecretScope + description: string + appId: string | null + appTitle: string | null + allowedHosts: Array + createdAt: string + updatedAt: string + ttlMs: number | null +} + +type SecretApprovalView = { + token: string + name: string + scope: SecretScope + requestedHost: string + currentAllowedHosts: Array +} + +type AccountSecretsPayload = { + ok: true + email: string + secrets: Array + approval: SecretApprovalView | null +} + +type SecretApprovalAction = 'approve' | 'reject' + +const approvalPathPrefix = '/account/secrets/approve' + +export function createAccountSecretsHandler(env: Env) { + return { + middleware: [], + async action({ request }) { + const user = await readAuthenticatedAppUser(request, env) + if (!user) { + return redirectToLogin(request) + } + + const payload = await buildAccountSecretsPayload({ + request, + env, + user, + }) + + return jsonResponse(payload) + }, + } satisfies BuildAction< + typeof routes.accountSecrets.method, + typeof routes.accountSecrets.pattern + > +} + +export function createAccountSecretsApiHandler(env: Env) { + return { + middleware: [], + async action({ request }) { + const user = await readAuthenticatedAppUser(request, env) + if (!user) { + return jsonResponse({ ok: false, error: 'Unauthorized.' }, 401) + } + + if (request.method === 'GET') { + const payload = await buildAccountSecretsPayload({ + request, + env, + user, + }) + return jsonResponse(payload) + } + + if (request.method !== 'POST') { + return jsonResponse({ ok: false, error: 'Method not allowed.' }, 405) + } + + const body = await request.json().catch(() => null) + if (!body || typeof body !== 'object') { + return jsonResponse({ ok: false, error: 'Invalid request body.' }, 400) + } + + const action = readApprovalAction(body) + if (!action) { + return jsonResponse({ ok: false, error: 'Invalid approval action.' }, 400) + } + + const token = readString(body, 'requestToken') + if (!token) { + return jsonResponse( + { ok: false, error: 'Approval request token is required.' }, + 400, + ) + } + + try { + const approval = await verifySecretHostApprovalToken(env, token) + if (approval.userId !== user.mcpUser.userId) { + return jsonResponse({ ok: false, error: 'Approval request mismatch.' }, 403) + } + + if (action === 'approve') { + const current = await listSecrets({ + env, + userId: user.mcpUser.userId, + scope: approval.scope, + secretContext: approval.secretContext, + }) + const secret = current.find( + (item) => + item.name === approval.name && item.scope === approval.scope, + ) + if (!secret) { + return jsonResponse({ ok: false, error: 'Secret not found.' }, 404) + } + await setSecretAllowedHosts({ + env, + userId: user.mcpUser.userId, + name: approval.name, + scope: approval.scope, + allowedHosts: [...secret.allowedHosts, approval.requestedHost], + secretContext: approval.secretContext, + }) + } + + const payload = await buildAccountSecretsPayload({ + request, + env, + user, + }) + return jsonResponse(payload) + } catch (error) { + return jsonResponse( + { + ok: false, + error: + error instanceof Error + ? error.message + : 'Unable to process approval request.', + }, + 400, + ) + } + }, + } satisfies BuildAction< + typeof routes.accountSecretsApi.method, + typeof routes.accountSecretsApi.pattern + > +} + +async function buildAccountSecretsPayload(input: { + request: Request + env: Env + user: NonNullable>> +}): Promise { + const url = new URL(input.request.url) + const approvalToken = url.pathname.startsWith(approvalPathPrefix) + ? url.searchParams.get('request') + : null + + const savedApps = await listSavedAppsForUser({ + env: input.env, + user: input.user, + }) + const appTitles = new Map(savedApps.map((app) => [app.id, app.title])) + const [userSecrets, appSecrets] = await Promise.all([ + listSecrets({ + env: input.env, + userId: input.user.mcpUser.userId, + scope: 'user', + }), + listAppSecretsByAppIds({ + env: input.env, + userId: input.user.mcpUser.userId, + appIds: savedApps.map((app) => app.id), + }), + ]) + + const secrets = [ + ...userSecrets.map((secret) => toAccountSecretListItem(secret, appTitles)), + ...Array.from(appSecrets.values()) + .flat() + .map((secret) => toAccountSecretListItem(secret, appTitles)), + ].sort((left, right) => { + return left.name.localeCompare(right.name) || left.scope.localeCompare(right.scope) + }) + + const approval = approvalToken + ? await resolveSecretApprovalView({ + env: input.env, + userId: input.user.mcpUser.userId, + token: approvalToken, + }).catch(() => null) + : null + + return { + ok: true, + email: input.user.email, + secrets, + approval, + } +} + +async function listSavedAppsForUser(input: { + env: Env + user: NonNullable>> +}) { + const apps = await Promise.all( + input.user.artifactOwnerIds.map((ownerId) => + listUiArtifactsByUserId(input.env.APP_DB, ownerId), + ), + ) + const dedupedIds = new Set() + return apps.flat().filter((app) => { + if (dedupedIds.has(app.id)) return false + dedupedIds.add(app.id) + return true + }) +} + +async function resolveSecretApprovalView(input: { + env: Env + userId: string + token: string +}) { + const approval = await verifySecretHostApprovalToken(input.env, input.token) + if (approval.userId !== input.userId) { + throw new Error('Approval request mismatch.') + } + const secrets = await listSecrets({ + env: input.env, + userId: input.userId, + scope: approval.scope, + secretContext: approval.secretContext, + }) + const secret = secrets.find( + (item) => item.name === approval.name && item.scope === approval.scope, + ) + if (!secret) { + throw new Error('Secret not found.') + } + return { + token: input.token, + name: approval.name, + scope: approval.scope, + requestedHost: approval.requestedHost, + currentAllowedHosts: secret.allowedHosts, + } satisfies SecretApprovalView +} + +function toAccountSecretListItem( + secret: { + name: string + scope: SecretScope + description: string + appId: string | null + allowedHosts: Array + createdAt: string + updatedAt: string + ttlMs: number | null + }, + appTitles: Map, +) { + return { + name: secret.name, + scope: secret.scope, + description: secret.description, + appId: secret.appId, + appTitle: secret.appId ? (appTitles.get(secret.appId) ?? null) : null, + allowedHosts: secret.allowedHosts, + createdAt: secret.createdAt, + updatedAt: secret.updatedAt, + ttlMs: secret.ttlMs, + } satisfies AccountSecretListItem +} + +function readString(body: object, key: string) { + const value = (body as Record)[key] + return typeof value === 'string' && value.trim() ? value.trim() : null +} + +function readApprovalAction(body: object): SecretApprovalAction | null { + const raw = readString(body, 'action') + return raw === 'approve' || raw === 'reject' ? raw : null +} + +function jsonResponse(body: Record, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json; charset=utf-8', + }, + }) +} diff --git a/packages/worker/src/app/handlers/account.ts b/packages/worker/src/app/handlers/account.ts index 763dc1e842..fef993a3e2 100644 --- a/packages/worker/src/app/handlers/account.ts +++ b/packages/worker/src/app/handlers/account.ts @@ -14,11 +14,7 @@ export const account = { return redirectToLogin(request) } - const response = render( - Layout({ - title: 'Account', - }), - ) + const response = render(Layout({ title: 'Account' })) if (setCookie) { response.headers.set('Set-Cookie', setCookie) } diff --git a/packages/worker/src/app/router.ts b/packages/worker/src/app/router.ts index ff68128223..19a5c50652 100644 --- a/packages/worker/src/app/router.ts +++ b/packages/worker/src/app/router.ts @@ -1,5 +1,9 @@ import { createRouter } from 'remix/fetch-router' import { account } from '#app/handlers/account.ts' +import { + createAccountSecretsApiHandler, + createAccountSecretsHandler, +} from '#app/handlers/account-secrets.ts' import { createAuthHandler } from '#app/handlers/auth.ts' import { chat } from '#app/handlers/chat.ts' import { @@ -43,6 +47,9 @@ export function createAppRouter(appEnv: AppEnv) { router.map(routes.login, login) router.map(routes.signup, signup) router.map(routes.account, account) + router.map(routes.accountSecrets, createAccountSecretsHandler(appEnv as Env)) + router.map(routes.accountSecretsApprove, createAccountSecretsHandler(appEnv as Env)) + router.map(routes.accountSecretsApi, createAccountSecretsApiHandler(appEnv as Env)) router.map(routes.savedUi, createSavedUiPageHandler(appEnv as Env)) router.map(routes.auth, createAuthHandler(appEnv)) router.map(routes.session, session) diff --git a/packages/worker/src/app/routes.ts b/packages/worker/src/app/routes.ts index cc50588b6a..09e4ead4c2 100644 --- a/packages/worker/src/app/routes.ts +++ b/packages/worker/src/app/routes.ts @@ -5,6 +5,9 @@ export const routes = route({ chat: '/chat', chatThread: '/chat/:threadId', savedUi: '/ui/:id', + accountSecrets: '/account/secrets', + accountSecretsApprove: '/account/secrets/approve', + accountSecretsApi: '/account/secrets.json', chatThreads: '/chat-threads', chatThreadsCreate: post('/chat-threads'), chatThreadsUpdate: post('/chat-threads/update'), diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 9f1473356f..f4303711a6 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -30,8 +30,9 @@ import { import { withCors } from './utils.ts' import { handleCapabilityReindexRequest } from './capability-maintenance.ts' import { handleSkillReindexRequest } from './skill-maintenance.ts' +import { CodemodeFetchGateway } from '#mcp/fetch-gateway.ts' -export { ChatAgent, HomeConnectorSession, HomeMCP, MCP } +export { ChatAgent, CodemodeFetchGateway, HomeConnectorSession, HomeMCP, MCP } const claudeWidgetDomainSuffix = '.claudemcpcontent.com' diff --git a/packages/worker/src/mcp/capabilities/meta/meta-run-skill.ts b/packages/worker/src/mcp/capabilities/meta/meta-run-skill.ts index 3795b0effe..787e6cd838 100644 --- a/packages/worker/src/mcp/capabilities/meta/meta-run-skill.ts +++ b/packages/worker/src/mcp/capabilities/meta/meta-run-skill.ts @@ -1,3 +1,4 @@ +import { exports as workerExports } from 'cloudflare:workers' import { z } from 'zod' import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' @@ -73,6 +74,7 @@ export const metaRunSkillCapability = defineDomainCapability( ctx.callerContext, row.code, shouldPassParams ? params : undefined, + workerExports, ) if (exec.error) { return { diff --git a/packages/worker/src/mcp/capabilities/secrets/domain.ts b/packages/worker/src/mcp/capabilities/secrets/domain.ts index f5a3637c62..88394e52cc 100644 --- a/packages/worker/src/mcp/capabilities/secrets/domain.ts +++ b/packages/worker/src/mcp/capabilities/secrets/domain.ts @@ -8,7 +8,7 @@ import { secretUpdateCapability } from './secret-update.ts' export const secretsDomain = defineDomain({ name: capabilityDomainNames.secrets, description: - 'Server-side secret references that can be discovered by name, resolved during execute-time code, and explicitly updated or deleted without placing raw secret values into prompts or saved app source.', + 'Server-side secret references that can be discovered by name, listed with allowed-host metadata, and explicitly updated or deleted without placing raw secret values into prompts or saved app source.', keywords: ['secret', 'credentials', 'reference', 'secure input'], capabilities: [ secretListCapability, diff --git a/packages/worker/src/mcp/capabilities/secrets/secret-get.ts b/packages/worker/src/mcp/capabilities/secrets/secret-get.ts index 48633ccabe..3c3e4d3a3a 100644 --- a/packages/worker/src/mcp/capabilities/secrets/secret-get.ts +++ b/packages/worker/src/mcp/capabilities/secrets/secret-get.ts @@ -11,7 +11,7 @@ export const secretGetCapability = defineDomainCapability( { name: 'secret_get', description: - 'Resolve a secret value for server-side execution. Use the injected `secrets.get(name, { scope })` or `secrets.require(name, { scope })` helper from execute-time code instead of calling this directly when possible.', + 'Resolve a secret value for server-side execution. Prefer placeholder-based network requests (`{{secret:name}}` or `{{secret:name|scope=app}}`) inside execute-time code so outbound host approvals remain enforced outside the sandbox.', keywords: ['secret', 'resolve', 'read', 'credential'], readOnly: true, idempotent: true, diff --git a/packages/worker/src/mcp/capabilities/secrets/secret-list.ts b/packages/worker/src/mcp/capabilities/secrets/secret-list.ts index a5de55de18..4461d69677 100644 --- a/packages/worker/src/mcp/capabilities/secrets/secret-list.ts +++ b/packages/worker/src/mcp/capabilities/secrets/secret-list.ts @@ -12,7 +12,7 @@ export const secretListCapability = defineDomainCapability( { name: 'secret_list', description: - 'List available secret references for the signed-in user without revealing secret values. When scope is omitted, results include every accessible scope in precedence order. Use the injected `secrets.list({ scope })` helper inside execute-time code when you want the same metadata from the sandbox.', + 'List available secret references for the signed-in user without revealing secret values. When scope is omitted, results include every accessible scope in precedence order. Use the injected `secrets.list({ scope })` helper inside execute-time code when you want the same metadata, including allowed hosts, from the sandbox.', keywords: ['secret', 'list', 'discovery', 'metadata', 'credentials'], readOnly: true, idempotent: true, @@ -45,6 +45,7 @@ export const secretListCapability = defineDomainCapability( scope: secret.scope, description: secret.description, app_id: secret.appId, + allowed_hosts: secret.allowedHosts, created_at: secret.createdAt, updated_at: secret.updatedAt, ttl_ms: secret.ttlMs, diff --git a/packages/worker/src/mcp/capabilities/secrets/secret-update.ts b/packages/worker/src/mcp/capabilities/secrets/secret-update.ts index bdf82d5808..1ca825fe80 100644 --- a/packages/worker/src/mcp/capabilities/secrets/secret-update.ts +++ b/packages/worker/src/mcp/capabilities/secrets/secret-update.ts @@ -12,7 +12,7 @@ export const secretUpdateCapability = defineDomainCapability( { name: 'secret_update', description: - 'Update an existing secret value or description for the signed-in user. Use this when rotating a secret or correcting its non-sensitive metadata.', + 'Update an existing secret value or description for the signed-in user. Use this when rotating a secret or correcting its non-sensitive metadata. This does not change allowed outbound hosts; host approval stays in the app UI.', keywords: ['secret', 'update', 'rotate', 'credential'], readOnly: false, idempotent: false, @@ -65,6 +65,7 @@ export const secretUpdateCapability = defineDomainCapability( scope: secret.scope, description: secret.description, app_id: secret.appId, + allowed_hosts: secret.allowedHosts, created_at: secret.createdAt, updated_at: secret.updatedAt, ttl_ms: secret.ttlMs, diff --git a/packages/worker/src/mcp/capabilities/secrets/shared.ts b/packages/worker/src/mcp/capabilities/secrets/shared.ts index cea52b391b..d0b9834393 100644 --- a/packages/worker/src/mcp/capabilities/secrets/shared.ts +++ b/packages/worker/src/mcp/capabilities/secrets/shared.ts @@ -6,6 +6,7 @@ export const secretMetadataSchema = z.object({ scope: z.enum(secretScopeValues), description: z.string(), app_id: z.string().nullable(), + allowed_hosts: z.array(z.string()), created_at: z.string(), updated_at: z.string(), ttl_ms: z.number().int().nonnegative().nullable(), diff --git a/packages/worker/src/mcp/capabilities/unified-search.test.ts b/packages/worker/src/mcp/capabilities/unified-search.test.ts index a41bc24591..7fd4810181 100644 --- a/packages/worker/src/mcp/capabilities/unified-search.test.ts +++ b/packages/worker/src/mcp/capabilities/unified-search.test.ts @@ -141,7 +141,7 @@ test('search can return standalone user secrets and nest app secrets on apps', a } expect(secret.scope).toBe('user') expect(secret.name).toBe('cloudflareToken') - expect(secret.usage).toContain('secrets.require') + expect(secret.usage).toContain('{{secret:') const app = result.matches.find((match) => match.type === 'app') if (!app || app.type !== 'app') { diff --git a/packages/worker/src/mcp/capabilities/unified-search.ts b/packages/worker/src/mcp/capabilities/unified-search.ts index 702f6d1d21..819fca1a1b 100644 --- a/packages/worker/src/mcp/capabilities/unified-search.ts +++ b/packages/worker/src/mcp/capabilities/unified-search.ts @@ -123,9 +123,7 @@ export type UnifiedSearchMatch = | UiArtifactSearchHit function buildSecretUsage(name: string) { - return `Resolve during execute with secrets.require(${JSON.stringify( - name, - )}, { scope: "user" }).` + return `Use in execute-time fetch placeholders like {{secret:${name}|scope=user}} and ask the user to approve each destination host in the app when needed.` } function rowToSecretHit( diff --git a/packages/worker/src/mcp/executor.ts b/packages/worker/src/mcp/executor.ts index 134b3b6df7..80f20208b3 100644 --- a/packages/worker/src/mcp/executor.ts +++ b/packages/worker/src/mcp/executor.ts @@ -3,15 +3,25 @@ import { normalizeCode, type ExecuteResult, } from '@cloudflare/codemode' +import { exports as workerExports } from 'cloudflare:workers' +type WorkerLoopbackExports = Exclude +import { type FetchGatewayProps } from '#mcp/fetch-gateway.ts' const charsPerToken = 4 const maxTokens = 6_000 const maxChars = maxTokens * charsPerToken -export function createExecuteExecutor(env: Env) { +export function createExecuteExecutor(input: { + env: Env + exports?: WorkerLoopbackExports + gatewayProps: FetchGatewayProps +}) { return new DynamicWorkerExecutor({ - loader: env.LOADER, + loader: input.env.LOADER, timeout: 90_000, + globalOutbound: (input.exports ?? workerExports)?.CodemodeFetchGateway({ + props: input.gatewayProps, + }), }) } @@ -22,30 +32,8 @@ export function wrapExecuteCode(code: string) { const result = await codemode.secret_list(options); return Array.isArray(result?.secrets) ? result.secrets : []; }; - const getSecret = async (name, options = {}) => { - const result = await codemode.secret_get({ - name, - ...(options && typeof options === 'object' ? options : {}), - }); - if (!result || result.found !== true || typeof result.value !== 'string') { - return null; - } - if (typeof result.scope === 'string') { - console.log(\`Secret used: \${result.scope}:\${name}\`); - } - return result.value; - }; - const requireSecret = async (name, options = {}) => { - const value = await getSecret(name, options); - if (value === null) { - throw new Error(\`Secret not found: \${name}\`); - } - return value; - }; const secrets = { list: listSecrets, - get: getSecret, - require: requireSecret, }; const userCode = (${normalized}); return await userCode(); diff --git a/packages/worker/src/mcp/fetch-gateway.ts b/packages/worker/src/mcp/fetch-gateway.ts new file mode 100644 index 0000000000..1387966f9b --- /dev/null +++ b/packages/worker/src/mcp/fetch-gateway.ts @@ -0,0 +1,159 @@ +import { WorkerEntrypoint } from 'cloudflare:workers' +import { + buildSecretHostApprovalUrl, + createSecretHostApprovalToken, +} from '#mcp/secrets/host-approval.ts' +import { resolveSecretForHost } from '#mcp/secrets/service.ts' +import { type SecretContext, type SecretScope } from '#mcp/secrets/types.ts' + +const secretPlaceholderRegex = + /\{\{secret:([a-zA-Z0-9._-]+)(?:\|scope=(session|app|user))?\}\}/g + +type FetchGatewayProps = { + baseUrl: string + userId: string | null + secretContext: SecretContext | null +} +export type { FetchGatewayProps } + +type ReferencedSecret = { + name: string + scope: SecretScope | null +} + +export class CodemodeFetchGateway extends WorkerEntrypoint { + async fetch(request: Request) { + const targetUrl = new URL(request.url) + const transformed = await expandSecretPlaceholders({ + request, + targetHost: targetUrl.hostname, + props: this.ctx.props, + env: this.env, + }) + return fetch(transformed) + } +} + +export async function expandSecretPlaceholders(input: { + request: Request + targetHost: string + props: FetchGatewayProps + env: Pick +}) { + ensureFetchAllowed(input.props) + const headers = new Headers(input.request.headers) + const requestBody = await readRequestBody(input.request) + const replacements = new Map() + const referencedSecrets = collectReferencedSecrets([ + input.request.url, + ...Array.from(headers.values()), + requestBody, + ]) + for (const referenced of referencedSecrets) { + const resolved = await resolveSecretForHost({ + env: input.env, + userId: input.props.userId!, + name: referenced.name, + scope: referenced.scope, + secretContext: input.props.secretContext, + host: input.targetHost, + }) + if (!resolved.found || typeof resolved.value !== 'string') { + throw new Error(`Secret "${referenced.name}" was not found.`) + } + if ('allowedForHost' in resolved && resolved.allowedForHost !== true) { + const approvalToken = await createSecretHostApprovalToken(input.env, { + userId: input.props.userId!, + name: referenced.name, + scope: resolved.scope ?? referenced.scope ?? 'user', + requestedHost: input.targetHost, + secretContext: input.props.secretContext, + }) + const approvalUrl = buildSecretHostApprovalUrl( + input.props.baseUrl, + approvalToken, + ) + throw new Error( + `Secret "${referenced.name}" is not allowed for host "${input.targetHost}". If this request is expected, ask the user whether this host should be added to the secret's allowed hosts: ${approvalUrl}`, + ) + } + const placeholder = buildSecretPlaceholder(referenced) + replacements.set(placeholder, resolved.value) + } + const nextUrl = replaceSecretPlaceholders(input.request.url, replacements) + for (const [key, value] of headers.entries()) { + headers.set(key, replaceSecretPlaceholders(value, replacements)) + } + const nextBody = + requestBody == null + ? undefined + : replaceSecretPlaceholders(requestBody, replacements) + return new Request(nextUrl, { + method: input.request.method, + headers, + body: shouldSendBody(input.request.method) ? nextBody : undefined, + redirect: input.request.redirect, + }) +} + +function ensureFetchAllowed(props: FetchGatewayProps) { + if (!props.userId) { + throw new Error( + 'Network requests that use secret placeholders require an authenticated user.', + ) + } +} + +function collectReferencedSecrets(values: Array) { + const deduped = new Map() + for (const value of values) { + if (!value) continue + for (const referenced of parseSecretPlaceholders(value)) { + deduped.set(buildSecretPlaceholder(referenced), referenced) + } + } + return Array.from(deduped.values()) +} + +function parseSecretPlaceholders(value: string) { + const secrets: Array = [] + for (const match of value.matchAll(secretPlaceholderRegex)) { + const name = match[1]?.trim() + if (!name) continue + const scope = match[2] + secrets.push({ + name, + scope: + scope === 'app' || scope === 'session' || scope === 'user' + ? scope + : null, + }) + } + return secrets +} + +function buildSecretPlaceholder(secret: ReferencedSecret) { + return secret.scope + ? `{{secret:${secret.name}|scope=${secret.scope}}}` + : `{{secret:${secret.name}}}` +} + +function replaceSecretPlaceholders( + value: string, + replacements: ReadonlyMap, +) { + let nextValue = value + for (const [placeholder, secretValue] of replacements.entries()) { + nextValue = nextValue.replaceAll(placeholder, secretValue) + } + return nextValue +} + +async function readRequestBody(request: Request) { + if (!shouldSendBody(request.method)) return null + return request.text() +} + +function shouldSendBody(method: string) { + return method !== 'GET' && method !== 'HEAD' +} diff --git a/packages/worker/src/mcp/generated-ui-api.ts b/packages/worker/src/mcp/generated-ui-api.ts index 00dceb2957..e9bee54c57 100644 --- a/packages/worker/src/mcp/generated-ui-api.ts +++ b/packages/worker/src/mcp/generated-ui-api.ts @@ -1,6 +1,7 @@ import { createRouter, type BuildAction } from 'remix/fetch-router' import { post, route } from 'remix/fetch-router/routes' import { z } from 'zod' +import { exports as workerExports } from 'cloudflare:workers' import { readAuthenticatedAppUser } from '#app/authenticated-user.ts' import { getAppBaseUrl } from '#app/app-base-url.ts' import { createMcpCallerContext } from '#mcp/context.ts' @@ -94,11 +95,7 @@ function createGeneratedUiApiRouter(env: Env) { router.map(generatedUiApiRoutes.execute, createGeneratedUiExecuteHandler(env)) router.map( generatedUiApiRoutes.listSecrets, - createGeneratedUiListSecretsHandler(env), - ) - router.map( - generatedUiApiRoutes.saveSecret, - createGeneratedUiSaveSecretHandler(env), + createGeneratedUiSecretsHandler(env), ) router.map( generatedUiApiRoutes.deleteSecret, @@ -201,6 +198,8 @@ function createGeneratedUiExecuteHandler(env: Env) { }, }), body.data.code, + undefined, + workerExports, ) if (result.error) { return jsonResponse( @@ -224,7 +223,7 @@ function createGeneratedUiExecuteHandler(env: Env) { > } -function createGeneratedUiListSecretsHandler(env: Env) { +function createGeneratedUiSecretsHandler(env: Env) { return { middleware: [], async action({ request, params }) { @@ -234,49 +233,40 @@ function createGeneratedUiListSecretsHandler(env: Env) { routeId: getGeneratedUiRouteId(params), }) if (context instanceof Response) return context - const url = new URL(request.url) - const scope = parseOptionalScope(url.searchParams.get('scope')) - if (url.searchParams.has('scope') && scope == null) { - return jsonResponse({ error: 'Invalid secret scope.' }, 400) + if (request.method === 'GET') { + const url = new URL(request.url) + const scope = parseOptionalScope(url.searchParams.get('scope')) + if (url.searchParams.has('scope') && scope == null) { + return jsonResponse({ error: 'Invalid secret scope.' }, 400) + } + const secrets = await listSecrets({ + env, + userId: context.user.userId, + scope, + secretContext: { + sessionId: context.sessionId, + appId: context.appId, + }, + }) + return jsonResponse({ + ok: true, + secrets: secrets.map((secret) => ({ + name: secret.name, + scope: secret.scope, + description: secret.description, + app_id: secret.appId, + allowed_hosts: secret.allowedHosts, + created_at: secret.createdAt, + updated_at: secret.updatedAt, + ttl_ms: secret.ttlMs, + })), + }) + } + + if (request.method !== 'POST') { + return jsonResponse({ error: 'Method not allowed.' }, 405) } - const secrets = await listSecrets({ - env, - userId: context.user.userId, - scope, - secretContext: { - sessionId: context.sessionId, - appId: context.appId, - }, - }) - return jsonResponse({ - ok: true, - secrets: secrets.map((secret) => ({ - name: secret.name, - scope: secret.scope, - description: secret.description, - app_id: secret.appId, - created_at: secret.createdAt, - updated_at: secret.updatedAt, - ttl_ms: secret.ttlMs, - })), - }) - }, - } satisfies BuildAction< - typeof generatedUiApiRoutes.listSecrets.method, - typeof generatedUiApiRoutes.listSecrets.pattern - > -} -function createGeneratedUiSaveSecretHandler(env: Env) { - return { - middleware: [], - async action({ request, params }) { - const context = await requireGeneratedUiSessionContext({ - request, - env, - routeId: getGeneratedUiRouteId(params), - }) - if (context instanceof Response) return context const body = secretMutationSchema.safeParse( await request.json().catch(() => null), ) @@ -304,6 +294,7 @@ function createGeneratedUiSaveSecretHandler(env: Env) { scope: saved.scope, description: saved.description, app_id: saved.appId, + allowed_hosts: saved.allowedHosts, created_at: saved.createdAt, updated_at: saved.updatedAt, ttl_ms: saved.ttlMs, @@ -321,8 +312,8 @@ function createGeneratedUiSaveSecretHandler(env: Env) { } }, } satisfies BuildAction< - typeof generatedUiApiRoutes.saveSecret.method, - typeof generatedUiApiRoutes.saveSecret.pattern + typeof generatedUiApiRoutes.listSecrets.method, + typeof generatedUiApiRoutes.listSecrets.pattern > } diff --git a/packages/worker/src/mcp/index.ts b/packages/worker/src/mcp/index.ts index 855d56b47b..bae2022bbd 100644 --- a/packages/worker/src/mcp/index.ts +++ b/packages/worker/src/mcp/index.ts @@ -1,4 +1,5 @@ import * as Sentry from '@sentry/cloudflare' +import { exports as workerExports } from 'cloudflare:workers' import { invariant } from '@epic-web/invariant' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/cfworker-provider.js' @@ -34,7 +35,7 @@ Quick start - Use 'meta_save_skill' only for workflows that are reasonably repeatable—patterns you expect to run again with similar structure or inputs. Do not save one-off tasks, unique ad-hoc work, or highly bespoke requests as skills; run those with 'execute' instead. Use 'meta_update_skill' to replace an existing skill's code in place. - When a saved skill declares parameters, pass values via meta_run_skill params; the codemode can read them from the params variable. - Use 'ui_save_app' to persist reusable UI source for later reopening via 'app_id'. Saved apps are user-scoped UI artifacts, not codemode skills. -- Use the injected 'secrets' helper during execute-time code to list metadata or resolve user/app/session secret references without placing secret values in prompts or saved app source. +- Use the injected 'secrets' helper during execute-time code to list secret metadata only; it does not return plaintext values. Kody source repository - Kody (this app and MCP server) is developed at https://github.com/kentcdodds/kody. When you launch a Cursor Cloud Agent to improve Kody itself, use that repository URL (unless the user explicitly points you at another fork or repo). @@ -78,7 +79,9 @@ How to use execute - Each capability call returns that capability's raw structured result value. - When chaining calls, read fields from the previous result using its outputSchema. - Chain multiple calls, use conditionals, and return structured results. -- The sandbox also injects a read-oriented 'secrets' helper for metadata/lookup: \`await secrets.list()\`, \`await secrets.list({ scope: 'app' })\`, \`await secrets.get('name')\`, \`await secrets.require('cloudflareToken', { scope: 'user' })\`. +- The sandbox injects a metadata-only 'secrets' helper: \`await secrets.list()\` or \`await secrets.list({ scope: 'app' })\`. +- Use normal \`fetch(...)\` for outbound HTTP. To inject a stored secret, place a placeholder such as \`{{secret:cloudflareToken}}\` or \`{{secret:cloudflareToken|scope=user}}\` in the URL, headers, or request body; the host resolves it server-side and blocks unapproved destinations. +- Saving or updating a secret does not authorize sending it anywhere. If a fetch fails because a host is not approved for that secret, ask the user whether to open the approval link and approve that host in the web app. - Mutating secret operations stay on \`codemode\`: for example \`await codemode.secret_update({ name: 'cloudflareToken', scope: 'app', value: '...' })\` or \`await codemode.secret_delete({ name: 'cloudflareToken', scope: 'app' })\`. - Your code must be an async arrow function that returns the result. - Example: const result = await codemode[capabilityName](args) @@ -108,6 +111,9 @@ class MCPBase extends McpAgent { getEnv() { return this.env } + getLoopbackExports() { + return this.ctx.exports as typeof workerExports + } requireDomain() { const { baseUrl } = this.getCallerContext() invariant( diff --git a/packages/worker/src/mcp/mcp-registration-agent.ts b/packages/worker/src/mcp/mcp-registration-agent.ts index 88745116b7..7ecb80869e 100644 --- a/packages/worker/src/mcp/mcp-registration-agent.ts +++ b/packages/worker/src/mcp/mcp-registration-agent.ts @@ -6,4 +6,5 @@ export type McpRegistrationAgent = { getEnv(): Env getCallerContext(): McpCallerContext requireDomain(): string + getLoopbackExports(): Cloudflare.Exports } diff --git a/packages/worker/src/mcp/mcp-server-e2e.test.ts b/packages/worker/src/mcp/mcp-server-e2e.test.ts index 9dca4d65a4..90c4d2ad66 100644 --- a/packages/worker/src/mcp/mcp-server-e2e.test.ts +++ b/packages/worker/src/mcp/mcp-server-e2e.test.ts @@ -386,6 +386,29 @@ async function authorizeWithPassword( return code } +async function loginToApp(origin: string, user: { email: string; password: string }) { + const response = await fetch(new URL('/auth', origin), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + mode: 'login', + email: user.email, + password: user.password, + }), + }) + const cookieHeader = response.headers.get('set-cookie') + if (!cookieHeader) { + const body = await response.text().catch(() => '') + throw new Error( + `Login did not return a session cookie (${response.status}): ${body}`, + ) + } + return cookieHeader.split(';')[0] ?? cookieHeader +} + type TestOAuthProvider = OAuthClientProvider & { waitForAuthorizationCode: () => Promise } @@ -1023,85 +1046,27 @@ test('generated ui sessions support secret storage, execute-time resolution, and scope: 'session', }), }) - expect(saveSessionSecretResponse.ok).toBe(true) - - const executeResponse = await fetch(executeUrl!, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify({ - code: `async () => { - const appSecret = await secrets.require('cloudflareToken') - const userSecret = await secrets.require('globalApiKey', { scope: 'user' }) - const sessionSecret = await secrets.require('ephemeralCode', { scope: 'session' }) - return { - appSecretLength: appSecret.length, - userSecretLength: userSecret.length, - sessionSecretLength: sessionSecret.length, - } - }`, - }), - }) - expect(executeResponse.ok).toBe(true) - const executePayload = (await executeResponse.json()) as { + const saveSessionSecretRaw = await saveSessionSecretResponse.text() + const saveSessionSecretPayload = JSON.parse(saveSessionSecretRaw) as { ok?: boolean - result?: { - appSecretLength?: number - userSecretLength?: number - sessionSecretLength?: number - } - logs?: Array + secret?: Record + error?: string } - expect(executePayload.ok).toBe(true) - expect(executePayload.result).toEqual({ - appSecretLength: 'secret-app-token'.length, - userSecretLength: 'secret-user-token'.length, - sessionSecretLength: '123456'.length, - }) - expect(executePayload.logs?.join('\n')).toContain( - 'Secret used: app:cloudflareToken', - ) - expect(executePayload.logs?.join('\n')).toContain( - 'Secret used: user:globalApiKey', - ) - expect(executePayload.logs?.join('\n')).toContain( - 'Secret used: session:ephemeralCode', - ) - - const sessionListExecuteResponse = await fetch(executeUrl!, { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify({ - code: `async () => { - return await secrets.list({ scope: 'session' }) - }`, - }), + expect( + saveSessionSecretResponse.ok, + `${saveSessionSecretResponse.status} ${saveSessionSecretRaw}`, + ).toBe(true) + expect(saveSessionSecretPayload.ok).toBe(true) + expect(saveSessionSecretPayload.secret).toEqual({ + name: 'ephemeralCode', + scope: 'session', + description: 'Session-only verification code', + app_id: null, + allowed_hosts: [], + created_at: expect.any(String), + updated_at: expect.any(String), + ttl_ms: expect.any(Number), }) - expect(sessionListExecuteResponse.ok).toBe(true) - const sessionListExecutePayload = - (await sessionListExecuteResponse.json()) as { - ok?: boolean - result?: Array> - } - expect(sessionListExecutePayload.ok).toBe(true) - expect(sessionListExecutePayload.result).toEqual([ - { - name: 'ephemeralCode', - scope: 'session', - description: 'Session-only verification code', - app_id: null, - created_at: expect.any(String), - updated_at: expect.any(String), - ttl_ms: expect.any(Number), - }, - ]) const listSecretsResponse = await fetch(`${secretsUrl!}?scope=app`, { headers: { @@ -1109,8 +1074,9 @@ test('generated ui sessions support secret storage, execute-time resolution, and Accept: 'application/json', }, }) - expect(listSecretsResponse.ok).toBe(true) - const listSecretsPayload = (await listSecretsResponse.json()) as { + const listSecretsRaw = await listSecretsResponse.text() + expect(listSecretsResponse.ok, listSecretsRaw).toBe(true) + const listSecretsPayload = JSON.parse(listSecretsRaw) as { ok?: boolean secrets?: Array<{ name?: string; scope?: string }> } @@ -1121,6 +1087,7 @@ test('generated ui sessions support secret storage, execute-time resolution, and scope: 'app', description: 'App-scoped Cloudflare deployment token', app_id: appId, + allowed_hosts: [], created_at: expect.any(String), updated_at: expect.any(String), ttl_ms: null, @@ -1159,12 +1126,96 @@ test('generated ui sessions support secret storage, execute-time resolution, and scope: 'app', description: 'Rotated Cloudflare deployment token', app_id: appId, + allowed_hosts: [], created_at: expect.any(String), updated_at: expect.any(String), ttl_ms: null, }) - const updatedValueExecuteResponse = await fetch(executeUrl!, { + const blockedFetchExecuteResponse = await fetch(executeUrl!, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + code: `async () => { + const response = await fetch('https://api.example.com/deploy', { + method: 'POST', + headers: { + Authorization: 'Bearer {{secret:cloudflareToken|scope=app}}', + 'X-Global-Key': '{{secret:globalApiKey|scope=user}}', + 'X-Session-Code': '{{secret:ephemeralCode|scope=session}}', + }, + body: JSON.stringify({ note: 'deploy' }), + }) + return { + status: response.status, + } + }`, + }), + }) + expect(blockedFetchExecuteResponse.status).toBe(400) + const blockedFetchExecutePayload = + (await blockedFetchExecuteResponse.json()) as { + ok?: boolean + error?: string + } + expect(blockedFetchExecutePayload.ok).toBe(false) + expect(blockedFetchExecutePayload.error).toContain( + 'Secret "cloudflareToken" is not allowed for host "api.example.com"', + ) + expect(blockedFetchExecutePayload.error).toContain( + 'ask the user whether this host should be added to the secret\'s allowed hosts', + ) + const approvalMatch = + blockedFetchExecutePayload.error?.match( + /https?:\/\/\S*\/account\/secrets\/approve\?request=[^\s)]+/, + ) ?? null + expect(approvalMatch).not.toBeNull() + + const appCookieHeader = await loginToApp(server.origin, database.user) + const approvalResponse = await fetch(approvalMatch![0]!, { + headers: { + Cookie: appCookieHeader, + }, + }) + expect(approvalResponse.ok).toBe(true) + + const approvalApiResponse = await fetch( + new URL('/account/secrets.json', server.origin), + { + method: 'POST', + headers: { + Cookie: appCookieHeader, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + action: 'approve', + requestToken: new URL(approvalMatch![0]!).searchParams.get('request'), + }), + }, + ) + const approvalApiRaw = await approvalApiResponse.text() + expect(approvalApiResponse.ok, approvalApiRaw).toBe(true) + const approvalApiPayload = JSON.parse(approvalApiRaw) as { + ok?: boolean + secrets?: Array> + } + expect(approvalApiPayload.ok).toBe(true) + expect(approvalApiPayload.secrets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'cloudflareToken', + scope: 'app', + allowedHosts: ['api.example.com'], + }), + ]), + ) + + const approvedFetchExecuteResponse = await fetch(executeUrl!, { method: 'POST', headers: { Authorization: `Bearer ${token}`, @@ -1173,22 +1224,36 @@ test('generated ui sessions support secret storage, execute-time resolution, and }, body: JSON.stringify({ code: `async () => { - const rotated = await secrets.require('cloudflareToken', { scope: 'app' }) - return { rotatedLength: rotated.length } + const response = await fetch('https://api.example.com/deploy', { + method: 'POST', + headers: { + Authorization: 'Bearer {{secret:cloudflareToken|scope=app}}', + 'X-Global-Key': '{{secret:globalApiKey|scope=user}}', + 'X-Session-Code': '{{secret:ephemeralCode|scope=session}}', + }, + body: JSON.stringify({ note: 'deploy' }), + }) + return { + ok: response.ok, + status: response.status, + } }`, }), }) - expect(updatedValueExecuteResponse.ok).toBe(true) - const updatedValueExecutePayload = - (await updatedValueExecuteResponse.json()) as { + expect(approvedFetchExecuteResponse.ok).toBe(true) + const approvedFetchExecutePayload = + (await approvedFetchExecuteResponse.json()) as { ok?: boolean result?: { - rotatedLength?: number + ok?: boolean + status?: number } } - expect(updatedValueExecutePayload.result?.rotatedLength).toBe( - 'rotated-app-token'.length, - ) + expect(approvedFetchExecutePayload.ok).toBe(true) + expect(approvedFetchExecutePayload.result).toEqual({ + ok: false, + status: 404, + }) const searchResult = await mcpClient.client.callTool({ name: 'search', diff --git a/packages/worker/src/mcp/run-codemode-registry.ts b/packages/worker/src/mcp/run-codemode-registry.ts index d23d5082e4..2feb8daa1d 100644 --- a/packages/worker/src/mcp/run-codemode-registry.ts +++ b/packages/worker/src/mcp/run-codemode-registry.ts @@ -3,6 +3,7 @@ import { type ResolvedProvider, type ToolProvider, } from '@cloudflare/codemode' +import { exports as workerExports } from 'cloudflare:workers' import { type McpCallerContext } from '@kody-internal/shared/chat.ts' import { buildParameterizedSkillCode } from '#mcp/skills/skill-parameters.ts' import { getCapabilityRegistryForContext } from '#mcp/capabilities/registry.ts' @@ -51,10 +52,22 @@ export async function runCodemodeWithRegistry( callerContext: McpCallerContext, code: string, params?: Record, + executorExports?: typeof workerExports, ) { const { createExecuteExecutor, wrapExecuteCode } = await import('#mcp/executor.ts') - const executor = createExecuteExecutor(env) + const executor = createExecuteExecutor({ + env, + exports: executorExports ?? workerExports, + gatewayProps: { + baseUrl: callerContext.baseUrl, + userId: callerContext.user?.userId ?? null, + secretContext: { + sessionId: callerContext.secretContext?.sessionId ?? null, + appId: callerContext.secretContext?.appId ?? null, + }, + }, + }) const provider = await buildCodemodeProvider(env, callerContext) const wrapped = params !== undefined diff --git a/packages/worker/src/mcp/secrets/allowed-hosts.ts b/packages/worker/src/mcp/secrets/allowed-hosts.ts new file mode 100644 index 0000000000..1d0e62c880 --- /dev/null +++ b/packages/worker/src/mcp/secrets/allowed-hosts.ts @@ -0,0 +1,31 @@ +export function normalizeHost(host: string) { + return host.trim().toLowerCase() +} + +export function normalizeAllowedHosts(hosts: Array) { + return Array.from( + new Set( + hosts + .map((host) => normalizeHost(host)) + .filter((host) => host.length > 0), + ), + ).sort() +} + +export function parseAllowedHosts(raw: string | null | undefined) { + if (!raw) return [] + try { + const parsed = JSON.parse(raw) + return Array.isArray(parsed) + ? normalizeAllowedHosts( + parsed.filter((value): value is string => typeof value === 'string'), + ) + : [] + } catch { + return [] + } +} + +export function stringifyAllowedHosts(hosts: Array) { + return JSON.stringify(normalizeAllowedHosts(hosts)) +} diff --git a/packages/worker/src/mcp/secrets/host-approval.ts b/packages/worker/src/mcp/secrets/host-approval.ts new file mode 100644 index 0000000000..5da5bf92ec --- /dev/null +++ b/packages/worker/src/mcp/secrets/host-approval.ts @@ -0,0 +1,82 @@ +import { decryptStringWithPurpose, encryptStringWithPurpose } from './crypto.ts' +import { normalizeHost } from './allowed-hosts.ts' +import { type SecretContext, type SecretScope } from './types.ts' + +const secretHostApprovalPurpose = 'secret-host-approval' +const defaultSecretHostApprovalTtlMs = 1000 * 60 * 60 * 24 + +export type SecretHostApprovalRequest = { + userId: string + name: string + scope: SecretScope + requestedHost: string + secretContext: SecretContext | null + iat: number + exp: number +} + +export async function createSecretHostApprovalToken( + env: Pick, + input: { + userId: string + name: string + scope: SecretScope + requestedHost: string + secretContext: SecretContext | null + ttlMs?: number + }, +) { + const now = Date.now() + const ttlMs = input.ttlMs ?? defaultSecretHostApprovalTtlMs + return encryptStringWithPurpose( + env, + secretHostApprovalPurpose, + JSON.stringify({ + userId: input.userId, + name: input.name.trim(), + scope: input.scope, + requestedHost: normalizeHost(input.requestedHost), + secretContext: input.secretContext ?? null, + iat: now, + exp: now + ttlMs, + } satisfies SecretHostApprovalRequest), + ) +} + +export async function verifySecretHostApprovalToken( + env: Pick, + token: string, +) { + const raw = await decryptStringWithPurpose( + env, + secretHostApprovalPurpose, + token, + ) + const parsed = JSON.parse(raw) as Partial + if ( + typeof parsed.userId !== 'string' || + typeof parsed.name !== 'string' || + typeof parsed.scope !== 'string' || + typeof parsed.requestedHost !== 'string' + ) { + throw new Error('Invalid secret host approval request.') + } + if (typeof parsed.exp === 'number' && Date.now() > parsed.exp) { + throw new Error('Secret host approval request has expired.') + } + return { + userId: parsed.userId, + name: parsed.name.trim(), + scope: parsed.scope as SecretScope, + requestedHost: normalizeHost(parsed.requestedHost), + secretContext: parsed.secretContext ?? null, + iat: typeof parsed.iat === 'number' ? parsed.iat : Date.now(), + exp: typeof parsed.exp === 'number' ? parsed.exp : Date.now(), + } satisfies SecretHostApprovalRequest +} + +export function buildSecretHostApprovalUrl(baseUrl: string, token: string) { + const url = new URL('/account/secrets/approve', baseUrl) + url.searchParams.set('request', token) + return url.toString() +} diff --git a/packages/worker/src/mcp/secrets/repo.ts b/packages/worker/src/mcp/secrets/repo.ts index 15d8deb384..c815bb6f85 100644 --- a/packages/worker/src/mcp/secrets/repo.ts +++ b/packages/worker/src/mcp/secrets/repo.ts @@ -9,6 +9,7 @@ type SecretMetadataRow = { binding_key: string name: string description: string + allowed_hosts: string created_at: string updated_at: string expires_at: string | null @@ -72,7 +73,7 @@ export async function getSecretEntry(input: { }): Promise { const row = await input.db .prepare( - `SELECT bucket_id, name, description, encrypted_value, created_at, updated_at + `SELECT bucket_id, name, description, encrypted_value, allowed_hosts, created_at, updated_at FROM secret_entries WHERE bucket_id = ? AND name = ? LIMIT 1`, @@ -93,12 +94,13 @@ export async function upsertSecretEntry(input: { await input.db .prepare( `INSERT INTO secret_entries ( - bucket_id, name, description, encrypted_value, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?) + bucket_id, name, description, encrypted_value, allowed_hosts, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(bucket_id, name) DO UPDATE SET description = excluded.description, encrypted_value = excluded.encrypted_value, + allowed_hosts = excluded.allowed_hosts, updated_at = excluded.updated_at`, ) .bind( @@ -106,6 +108,7 @@ export async function upsertSecretEntry(input: { input.row.name, input.row.description, input.row.encrypted_value, + input.row.allowed_hosts, input.row.created_at ?? now, input.row.updated_at ?? now, ) @@ -130,7 +133,7 @@ export async function listSecretMetadataForBucket(input: { }): Promise> { const { results } = await input.db .prepare( - `SELECT ? AS scope, ? AS binding_key, name, description, created_at, updated_at, ? AS expires_at + `SELECT ? AS scope, ? AS binding_key, name, description, allowed_hosts, created_at, updated_at, ? AS expires_at FROM secret_entries WHERE bucket_id = ? ORDER BY name ASC`, @@ -153,7 +156,7 @@ export async function listUserScopeSecretMetadata(input: { const now = input.now ?? new Date().toISOString() const { results } = await input.db .prepare( - `SELECT b.scope, b.binding_key, e.name, e.description, e.created_at, e.updated_at, b.expires_at + `SELECT b.scope, b.binding_key, e.name, e.description, e.allowed_hosts, e.created_at, e.updated_at, b.expires_at FROM secret_buckets b JOIN secret_entries e ON e.bucket_id = b.id WHERE b.user_id = ? AND b.scope = 'user' @@ -176,7 +179,7 @@ export async function listAppScopeSecretMetadata(input: { const placeholders = input.appIds.map(() => '?').join(', ') const { results } = await input.db .prepare( - `SELECT b.scope, b.binding_key, e.name, e.description, e.created_at, e.updated_at, b.expires_at + `SELECT b.scope, b.binding_key, e.name, e.description, e.allowed_hosts, e.created_at, e.updated_at, b.expires_at FROM secret_buckets b JOIN secret_entries e ON e.bucket_id = b.id WHERE b.user_id = ? AND b.scope = 'app' @@ -207,6 +210,7 @@ function mapSecretEntryRow(row: Record): SecretEntryRow { name: String(row['name']), description: String(row['description']), encrypted_value: String(row['encrypted_value']), + allowed_hosts: String(row['allowed_hosts']), created_at: String(row['created_at']), updated_at: String(row['updated_at']), } @@ -218,6 +222,7 @@ function mapSecretMetadataRow(row: Record): SecretMetadataRow { binding_key: String(row['binding_key']), name: String(row['name']), description: String(row['description']), + allowed_hosts: String(row['allowed_hosts']), created_at: String(row['created_at']), updated_at: String(row['updated_at']), expires_at: row['expires_at'] == null ? null : String(row['expires_at']), diff --git a/packages/worker/src/mcp/secrets/service.ts b/packages/worker/src/mcp/secrets/service.ts index 402f5822e0..bfc8af4393 100644 --- a/packages/worker/src/mcp/secrets/service.ts +++ b/packages/worker/src/mcp/secrets/service.ts @@ -1,3 +1,9 @@ +import { + normalizeAllowedHosts, + normalizeHost, + parseAllowedHosts, + stringifyAllowedHosts, +} from './allowed-hosts.ts' import { decryptSecretValue, encryptSecretValue } from './crypto.ts' import { deleteSecretEntry, @@ -60,6 +66,7 @@ export type ResolvedSecret = { found: boolean value: string | null scope: SecretScope | null + allowedHosts: Array } export async function saveSecret( @@ -94,6 +101,7 @@ export async function saveSecret( name, description, encrypted_value: await encryptSecretValue(input.env, value), + allowed_hosts: existingEntry?.allowed_hosts ?? '[]', created_at: existingEntry?.created_at ?? now, updated_at: now, }, @@ -103,6 +111,9 @@ export async function saveSecret( scope: input.scope, description, appId: input.scope === 'app' ? bucket.binding_key : null, + allowedHosts: existingEntry + ? parseAllowedHosts(existingEntry.allowed_hosts) + : [], createdAt: existingEntry?.created_at ?? now, updatedAt: now, expiresAt: bucket.expires_at, @@ -132,6 +143,7 @@ export async function listSecrets( scope: row.scope, description: row.description, appId: row.scope === 'app' ? row.binding_key : null, + allowedHosts: parseAllowedHosts(row.allowed_hosts), createdAt: row.created_at, updatedAt: row.updated_at, expiresAt: row.expires_at, @@ -163,12 +175,14 @@ export async function resolveSecret( found: true, value: await decryptSecretValue(input.env, entry.encrypted_value), scope, + allowedHosts: parseAllowedHosts(entry.allowed_hosts), } } return { found: false, value: null, scope: null, + allowedHosts: [], } } @@ -233,6 +247,7 @@ export async function updateSecret( encrypted_value: hasValueUpdate ? await encryptSecretValue(input.env, nextValue!) : existingEntry.encrypted_value, + allowed_hosts: existingEntry.allowed_hosts, created_at: existingEntry.created_at, updated_at: now, }, @@ -242,6 +257,7 @@ export async function updateSecret( scope: input.scope, description: nextDescription, appId: input.scope === 'app' ? bucket.binding_key : null, + allowedHosts: parseAllowedHosts(existingEntry.allowed_hosts), createdAt: existingEntry.created_at, updatedAt: now, expiresAt: bucket.expires_at, @@ -285,6 +301,7 @@ export async function listAppSecretsByAppIds(input: { scope: row.scope, description: row.description, appId, + allowedHosts: parseAllowedHosts(row.allowed_hosts), createdAt: row.created_at, updatedAt: row.updated_at, expiresAt: row.expires_at, @@ -418,6 +435,7 @@ function toSecretMetadata(input: { scope: SecretScope description: string appId: string | null + allowedHosts: Array createdAt: string updatedAt: string expiresAt: string | null @@ -427,6 +445,7 @@ function toSecretMetadata(input: { scope: input.scope, description: input.description, appId: input.appId, + allowedHosts: normalizeAllowedHosts(input.allowedHosts), createdAt: input.createdAt, updatedAt: input.updatedAt, ttlMs: @@ -435,3 +454,66 @@ function toSecretMetadata(input: { : Math.max(0, new Date(input.expiresAt).getTime() - Date.now()), } } + +export async function setSecretAllowedHosts(input: { + env: Pick + userId: string + name: string + scope: SecretScope + allowedHosts: Array + secretContext?: SecretContext | null +}) { + const name = input.name.trim() + if (!name) { + throw new Error('Secret name is required.') + } + const bucket = await getExistingBucketForScope({ + db: input.env.APP_DB, + userId: input.userId, + scope: input.scope, + secretContext: input.secretContext ?? null, + }) + if (!bucket) { + throw new Error('Secret not found for this scope.') + } + const existingEntry = await getSecretEntry({ + db: input.env.APP_DB, + bucketId: bucket.id, + name, + }) + if (!existingEntry) { + throw new Error('Secret not found for this scope.') + } + const now = new Date().toISOString() + await upsertSecretEntry({ + db: input.env.APP_DB, + row: { + ...existingEntry, + allowed_hosts: stringifyAllowedHosts(input.allowedHosts), + updated_at: now, + }, + }) + return toSecretMetadata({ + name, + scope: input.scope, + description: existingEntry.description, + appId: input.scope === 'app' ? bucket.binding_key : null, + allowedHosts: input.allowedHosts, + createdAt: existingEntry.created_at, + updatedAt: now, + expiresAt: bucket.expires_at, + }) +} + +export async function resolveSecretForHost(input: ResolveSecretInput & { + host: string +}) { + const normalizedHost = normalizeHost(input.host) + const resolved = await resolveSecret(input) + if (!resolved.found) return resolved + return { + ...resolved, + allowedForHost: resolved.allowedHosts.includes(normalizedHost), + } +} + diff --git a/packages/worker/src/mcp/secrets/types.ts b/packages/worker/src/mcp/secrets/types.ts index c9cffe58d0..e21fe65b45 100644 --- a/packages/worker/src/mcp/secrets/types.ts +++ b/packages/worker/src/mcp/secrets/types.ts @@ -17,6 +17,7 @@ export type SecretEntryRow = { name: string description: string encrypted_value: string + allowed_hosts: string created_at: string updated_at: string } @@ -26,6 +27,7 @@ export type SecretMetadata = { scope: SecretScope description: string appId: string | null + allowedHosts: Array createdAt: string updatedAt: string ttlMs: number | null diff --git a/packages/worker/src/mcp/tools/execute.ts b/packages/worker/src/mcp/tools/execute.ts index 98eed26d68..f01149a488 100644 --- a/packages/worker/src/mcp/tools/execute.ts +++ b/packages/worker/src/mcp/tools/execute.ts @@ -40,6 +40,18 @@ Each capability call resolves to the raw returned value itself, not an MCP wrapper object. When chaining calls, read fields from the previous result using the capability's \`outputSchema\` from \`search\` with \`detail: true\`. +Network access: +- Regular \`fetch(...)\` is available inside the sandbox and is routed through a host-side gateway. +- To inject a saved secret into a request, use a placeholder string such as \`{{secret:cloudflareToken}}\` or \`{{secret:cloudflareToken|scope=user}}\` in the URL, headers, or request body. +- Secret placeholders only work for hosts that the user has already approved for that secret. +- If a request is blocked because the host is not approved, do not retry blindly. Ask the user whether they want to approve that host, then provide the approval link from the error message. +- Saving or updating a secret does not authorize outbound use automatically. Host approval happens separately in the app. + +Secrets helper: +- \`await secrets.list()\` returns secret metadata only. It does not reveal secret values. +- Use \`secrets.list({ scope: 'app' })\` to inspect available secret names, descriptions, scopes, and allowed hosts before building a request. +- Do not expect \`secrets.get(...)\` or \`secrets.require(...)\` to be available in execute-time code. + Your code must be an async arrow function that returns the result. Examples: @@ -97,7 +109,14 @@ export async function registerExecuteTool(agent: McpRegistrationAgent) { 'mcp.tool': 'execute', }, }, - async () => runCodemodeWithRegistry(env, callerContext, code), + async () => + runCodemodeWithRegistry( + env, + callerContext, + code, + undefined, + agent.getLoopbackExports(), + ), ) const durationMs = Math.round(performance.now() - startedAt) diff --git a/packages/worker/src/mcp/tools/open-generated-ui.ts b/packages/worker/src/mcp/tools/open-generated-ui.ts index 8616e014a5..9231bc964d 100644 --- a/packages/worker/src/mcp/tools/open-generated-ui.ts +++ b/packages/worker/src/mcp/tools/open-generated-ui.ts @@ -21,7 +21,8 @@ Behavior: - After rendering, the shell automatically reports the rendered widget size to the host via the standard MCP Apps \`ui/notifications/size-changed\` notification. - \`executeCode(code)\` runs server-side code through the generated UI session when available, keeping secret resolution on the server. - \`saveSecret({ name, value, description?, scope? })\`, \`listSecrets({ scope? })\`, and \`deleteSecret({ name, scope? })\` let the UI manage secret references without embedding raw values in generated code. -- Secret metadata returned by the UI helpers includes \`created_at\`, \`updated_at\`, and \`ttl_ms\` when available so the UI can explain lifecycle and expiry. +- Saving a secret does not authorize outbound use automatically. If generated code later needs to send that secret to a host, the agent must ask the user to approve that host through the app approval flow. +- Secret metadata returned by the UI helpers includes \`allowed_hosts\`, \`created_at\`, \`updated_at\`, and \`ttl_ms\` when available so the UI can explain lifecycle, approval state, and expiry. Mini standard library: \`\`\`ts @@ -32,8 +33,8 @@ declare global { openLink(url: string): boolean toggleFullscreen(): Promise<'inline' | 'fullscreen' | 'pip' | null> executeCode(code: string): Promise - saveSecret(input: { name: string; value: string; description?: string; scope?: 'session' | 'app' | 'user' }): Promise<{ ok: boolean; secret?: { name: string; scope: 'session' | 'app' | 'user'; description: string; app_id: string | null; created_at: string; updated_at: string; ttl_ms: number | null }; error?: string }> - listSecrets(input?: { scope?: 'session' | 'app' | 'user' }): Promise> + saveSecret(input: { name: string; value: string; description?: string; scope?: 'session' | 'app' | 'user' }): Promise<{ ok: boolean; secret?: { name: string; scope: 'session' | 'app' | 'user'; description: string; app_id: string | null; allowed_hosts: string[]; created_at: string; updated_at: string; ttl_ms: number | null }; error?: string }> + listSecrets(input?: { scope?: 'session' | 'app' | 'user' }): Promise> deleteSecret(input: { name: string; scope?: 'session' | 'app' | 'user' }): Promise<{ ok: boolean; deleted?: boolean; error?: string }> } } diff --git a/packages/worker/src/test-support/cloudflare-workers-stub.ts b/packages/worker/src/test-support/cloudflare-workers-stub.ts index 08b9ba6677..b6268929a3 100644 --- a/packages/worker/src/test-support/cloudflare-workers-stub.ts +++ b/packages/worker/src/test-support/cloudflare-workers-stub.ts @@ -8,4 +8,16 @@ export class DurableObject { } } +export class WorkerEntrypoint { + protected readonly ctx: { props: TProps } + protected readonly env: TEnv + + constructor(ctx: { props: TProps }, env: TEnv) { + this.ctx = ctx + this.env = env + } +} + export class RpcTarget {} + +export const exports = {} From 4c3bc651a28f4750cc2fc3bdfc84492226a6c0f1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 27 Mar 2026 01:04:58 +0000 Subject: [PATCH 02/23] Fix account secrets approval flow Co-authored-by: Kent C. Dodds --- packages/worker/client/routes/account.tsx | 2 ++ .../src/app/handlers/account-secrets.ts | 35 +++++++++---------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/packages/worker/client/routes/account.tsx b/packages/worker/client/routes/account.tsx index 0e8340c1f3..6009fbf556 100644 --- a/packages/worker/client/routes/account.tsx +++ b/packages/worker/client/routes/account.tsx @@ -216,6 +216,7 @@ export function AccountRoute(handle: Handle) { + + + {approval && !isRefreshingForLocationChange ? ( +
+
+

+ Approve host access +

+

+ Allow {approval.requestedHost} to receive secret{' '} + {approval.name} from the{' '} + {getScopeLabel(approval.scope)} scope. +

+

+ Current allowed hosts:{' '} + {approval.currentAllowedHosts.length > 0 + ? approval.currentAllowedHosts.join(', ') + : 'none'} +

+
+
+ + +
+
+ ) : null} + + {status === 'loading' ? ( +

Loading secrets…

+ ) : null} + {message ? ( +

+ {message} +

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

+ {selection.isCreating ? 'New secret' : selectedSecret?.name} +

+

+ {selection.isCreating + ? 'Create a new user or app secret.' + : 'Update the secret value and metadata for this entry.'} +

+
+ +
+ + + +
+ + {editorState.scope === 'app' + ? editorAppCombobox({ + id: 'secret-editor-app', + label: 'App', + placeholder: 'Choose an app', + value: editorState.appId, + options: appOptions, + onChange: (appId) => { + editorState = { + ...editorState, + appId, + } + handle.update() + }, + inputCss, + listCss: comboboxListCss, + optionCss: comboboxOptionCss, + }) + : null} + +