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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,7 @@ export type GeneratedUiRuntimeBootstrap = {

export const generatedUiRuntimeModuleSpecifier = '@kody/utils' as const

export function buildGeneratedUiRuntimeImportMap(
runtimeScriptHref: string,
) {
export function buildGeneratedUiRuntimeImportMap(runtimeScriptHref: string) {
const importMapJson = escapeInlineScriptSource(
JSON.stringify({
imports: {
Expand All @@ -70,4 +68,3 @@ window.params = window.__kodyAppParams;
</script>
`.trim()
}

53 changes: 51 additions & 2 deletions packages/worker/client/mcp-apps/generated-ui-widget-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,10 @@ export const kodyWidget = new Proxy(Object.create(null), {
return Reflect.ownKeys(getKodyWidget())
},
getOwnPropertyDescriptor(_target, property) {
const descriptor = Reflect.getOwnPropertyDescriptor(getKodyWidget(), property)
const descriptor = Reflect.getOwnPropertyDescriptor(
getKodyWidget(),
property,
)
if (!descriptor) {
return undefined
}
Expand Down Expand Up @@ -374,6 +377,49 @@ function extractApprovalDetails(
fallbackSecretNames: Array<unknown> = [],
): ApprovalDetails {
const text = typeof message === 'string' ? message : String(message ?? '')
const hostBatchPrefix = 'Secrets require host approval:'
if (text.startsWith(hostBatchPrefix)) {
const raw = text.slice(hostBatchPrefix.length).trim()
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) {
const secretNames = normalizeSecretNameList([
...parsed
.map((entry) =>
entry && typeof entry.secretName === 'string'
? entry.secretName
: null,
)
.filter((entry): entry is string => Boolean(entry)),
...fallbackSecretNames,
])
const first = parsed.find(
(entry) =>
entry &&
typeof entry.approvalUrl === 'string' &&
typeof entry.host === 'string',
)
if (first) {
return {
message: text,
approvalUrl: first.approvalUrl,
host: first.host,
secretNames,
}
}
if (secretNames.length > 0) {
return {
message: text,
approvalUrl: null,
host: null,
secretNames,
}
}
}
} catch {
// Fall through to legacy parsing.
}
}
const secretNames = normalizeSecretNameList([
...Array.from(text.matchAll(/Secret "([^"]+)"/g))
.map((match) => match[1])
Expand Down Expand Up @@ -1493,7 +1539,10 @@ export function initializeGeneratedUiRuntime() {
return normalizeFetchWithSecretsResult(result)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (message.includes('not allowed for host')) {
if (
message.includes('not allowed for host') ||
message.includes('Secrets require host approval:')
) {
const approval = extractApprovalDetails(message, fallbackSecretNames)
return {
ok: false,
Expand Down
1 change: 1 addition & 0 deletions packages/worker/client/routes/account-approval-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type ApprovalView = {
name: string
scope: ApprovalScope
requestedHost: string
requestedCapability: string | null
currentAllowedHosts: Array<string>
}

Expand Down
48 changes: 44 additions & 4 deletions packages/worker/client/routes/account-secrets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,28 @@ function buildSecretsHref(pathname: string, search = getCurrentSearch()) {
return `${pathname}${search}`
}

function readCapabilityPrefill(href: string) {
const url = new URL(href, 'http://localhost')
const value = url.searchParams.get('capability')
return value?.trim() ? value.trim() : null
}

function applyCapabilityPrefill(state: EditorState, capability: string | null) {
if (!capability) return state
if (state.allowedCapabilities.some((entry) => entry.trim() === capability)) {
return state
}
const nextAllowedCapabilities =
state.allowedCapabilities.length === 1 &&
state.allowedCapabilities[0]?.trim() === ''
? [capability]
: [...state.allowedCapabilities, capability]
return {
...state,
allowedCapabilities: nextAllowedCapabilities,
}
}

function buildSecretHref(secret: {
name: string
scope: SecretScope
Expand Down Expand Up @@ -258,7 +280,8 @@ function getDataRefreshKey(href: string) {
const url = new URL(href, 'http://localhost')
const request = url.searchParams.get('request') ?? ''
const requestedHost = url.searchParams.get('allowed-host') ?? ''
return `${url.pathname}?request=${request}&allowed-host=${requestedHost}`
const requestedCapability = url.searchParams.get('capability') ?? ''
return `${url.pathname}?request=${request}&allowed-host=${requestedHost}&capability=${requestedCapability}`
}

function readFilterState(
Expand Down Expand Up @@ -359,15 +382,25 @@ export function AccountSecretsRoute(handle: Handle) {
function syncEditorState(selection: SelectionState) {
deleteSecretCheck.reset()
showSecretValue = false
const capabilityPrefill = readCapabilityPrefill(getCurrentHref())
if (selection.isCreating) {
editorState = createEmptyEditorState(apps)
editorState = applyCapabilityPrefill(
createEmptyEditorState(apps),
capabilityPrefill,
)
return
}
if (selectedSecret) {
editorState = createEditorStateFromSecret(selectedSecret)
editorState = applyCapabilityPrefill(
createEditorStateFromSecret(selectedSecret),
capabilityPrefill,
)
return
}
editorState = createEmptyEditorState(apps)
editorState = applyCapabilityPrefill(
createEmptyEditorState(apps),
capabilityPrefill,
)
}

function applyPayload(
Expand Down Expand Up @@ -471,6 +504,7 @@ export function AccountSecretsRoute(handle: Handle) {
const nextUrl = new URL(nextHref, window.location.href)
nextUrl.searchParams.delete('request')
nextUrl.searchParams.delete('allowed-host')
nextUrl.searchParams.delete('capability')
navigate(`${nextUrl.pathname}${nextUrl.search}`)
lastLoadedDataKey = getDataRefreshKey(nextUrl.toString())
}
Expand Down Expand Up @@ -761,6 +795,12 @@ export function AccountSecretsRoute(handle: Handle) {
<code>{approval.name}</code> from the{' '}
{getScopeLabel(approval.scope)} scope.
</p>
{approval.requestedCapability ? (
<p css={{ margin: 0, color: colors.textMuted }}>
Requested capability:{' '}
<code>{approval.requestedCapability}</code>
</p>
) : null}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<p css={{ margin: 0, color: colors.textMuted }}>
Current allowed hosts:{' '}
{approval.currentAllowedHosts.length > 0
Expand Down
10 changes: 10 additions & 0 deletions packages/worker/src/app/handlers/account-secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type SecretApprovalView = {
name: string
scope: SecretScope
requestedHost: string
requestedCapability: string | null
currentAllowedHosts: Array<string>
}

Expand Down Expand Up @@ -164,6 +165,7 @@ async function buildAccountSecretsPayload(input: {
const url = new URL(input.request.url)
const approvalToken = url.searchParams.get('request')
const requestedApprovalHost = readApprovalHost(url)
const requestedCapability = readRequestedCapability(url)

const savedApps =
input.savedApps ??
Expand Down Expand Up @@ -191,6 +193,7 @@ async function buildAccountSecretsPayload(input: {
userId: input.user.mcpUser.userId,
token: approvalToken,
requestedHost: requestedApprovalHost,
requestedCapability,
}).catch(() => null)
: null

Expand Down Expand Up @@ -272,6 +275,7 @@ async function resolveSecretApprovalView(input: {
userId: string
token: string
requestedHost: string | null
requestedCapability: string | null
}) {
const approval = await verifySecretHostApprovalToken(input.env, input.token)
if (approval.userId !== input.userId) {
Expand Down Expand Up @@ -300,6 +304,7 @@ async function resolveSecretApprovalView(input: {
name: approval.name,
scope: approval.scope,
requestedHost: approval.requestedHost,
requestedCapability: input.requestedCapability,
currentAllowedHosts: secret.allowedHosts,
} satisfies SecretApprovalView
}
Expand Down Expand Up @@ -632,6 +637,11 @@ function readApprovalHost(url: URL) {
return value?.trim() ? value.trim() : null
}

function readRequestedCapability(url: URL) {
const value = url.searchParams.get('capability')
return value?.trim() ? value.trim() : null
}

function readString(body: object, key: string) {
const value = (body as Record<string, unknown>)[key]
return typeof value === 'string' && value.trim() ? value.trim() : null
Expand Down
97 changes: 96 additions & 1 deletion packages/worker/src/mcp/executor.node.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { expect, test } from 'vitest'
import {
createCapabilitySecretAccessDeniedBatchMessage,
createCapabilitySecretAccessDeniedMessage,
createHostSecretAccessDeniedBatchMessage,
createMissingSecretMessage,
} from '#mcp/secrets/errors.ts'
import { formatExecutionOutput, getExecutionErrorDetails } from './executor.ts'
Expand All @@ -10,17 +12,20 @@ test('getExecutionErrorDetails returns concrete guidance for capability access d
createCapabilitySecretAccessDeniedMessage(
'cloudflareToken',
'cloudflare_rest',
'https://example.com/account/secrets/user/cloudflareToken?capability=cloudflare_rest',
),
)

expect(getExecutionErrorDetails(error)).toEqual({
kind: 'secret_capability_access_required',
message:
'Secret "cloudflareToken" is not allowed for capability "cloudflare_rest". If this capability should be able to use the secret, ask the user whether to add "cloudflare_rest" to the secret\'s allowed capabilities in the account secrets UI, then retry after they approve that policy change.',
'Secret "cloudflareToken" is not allowed for capability "cloudflare_rest". If this capability should be able to use the secret, ask the user whether to add "cloudflare_rest" to the secret\'s allowed capabilities in the account secrets UI, then retry after they approve that policy change. Approval link: https://example.com/account/secrets/user/cloudflareToken?capability=cloudflare_rest',
nextStep:
"Ask the user whether this capability should be allowed to use the secret. If they approve, help them add this capability name to the secret's allowed capabilities in the account secrets UI, then retry.",
secretNames: ['cloudflareToken'],
capabilityName: 'cloudflare_rest',
approvalUrl:
'https://example.com/account/secrets/user/cloudflareToken?capability=cloudflare_rest',
suggestedAction: {
type: 'edit_secret_policy',
policyField: 'allowed_capabilities',
Expand All @@ -34,6 +39,7 @@ test('formatExecutionOutput includes capability access next step', () => {
createCapabilitySecretAccessDeniedMessage(
'cloudflareToken',
'cloudflare_rest',
'https://example.com/account/secrets/user/cloudflareToken?capability=cloudflare_rest',
),
),
} as const
Expand All @@ -52,3 +58,92 @@ test('formatExecutionOutput keeps missing secret guidance intact', () => {
'Open a generated UI so the user can provide and save this secret',
)
})

test('getExecutionErrorDetails returns batch capability approvals', () => {
const error = new Error(
createCapabilitySecretAccessDeniedBatchMessage([
{
secretName: 'lutronUsername',
capabilityName: 'home_lutron_set_credentials',
approvalUrl:
'https://example.com/account/secrets/user/lutronUsername?capability=home_lutron_set_credentials',
},
{
secretName: 'lutronPassword',
capabilityName: 'home_lutron_set_credentials',
approvalUrl:
'https://example.com/account/secrets/user/lutronPassword?capability=home_lutron_set_credentials',
},
]),
)

expect(getExecutionErrorDetails(error)).toEqual({
kind: 'secret_capability_access_required_batch',
message:
'Secrets require capability approval: [{"secretName":"lutronUsername","capabilityName":"home_lutron_set_credentials","approvalUrl":"https://example.com/account/secrets/user/lutronUsername?capability=home_lutron_set_credentials"},{"secretName":"lutronPassword","capabilityName":"home_lutron_set_credentials","approvalUrl":"https://example.com/account/secrets/user/lutronPassword?capability=home_lutron_set_credentials"}]',
nextStep:
'Ask the user whether they want to approve these capabilities for the listed secrets in the account secrets UI, then retry after approval.',
missingApprovals: [
{
secretName: 'lutronUsername',
capabilityName: 'home_lutron_set_credentials',
approvalUrl:
'https://example.com/account/secrets/user/lutronUsername?capability=home_lutron_set_credentials',
},
{
secretName: 'lutronPassword',
capabilityName: 'home_lutron_set_credentials',
approvalUrl:
'https://example.com/account/secrets/user/lutronPassword?capability=home_lutron_set_credentials',
},
],
suggestedAction: {
type: 'edit_secret_policy',
policyField: 'allowed_capabilities',
},
})
})

test('getExecutionErrorDetails returns batch host approvals', () => {
const error = new Error(
createHostSecretAccessDeniedBatchMessage([
{
secretName: 'cloudflareToken',
host: 'api.cloudflare.com',
approvalUrl:
'https://example.com/account/secrets/user/cloudflareToken?allowed-host=api.cloudflare.com&request=token',
},
{
secretName: 'slackToken',
host: 'slack.com',
approvalUrl:
'https://example.com/account/secrets/user/slackToken?allowed-host=slack.com&request=token',
},
]),
)

expect(getExecutionErrorDetails(error)).toEqual({
kind: 'host_approval_required_batch',
message:
'Secrets require host approval: [{"secretName":"cloudflareToken","host":"api.cloudflare.com","approvalUrl":"https://example.com/account/secrets/user/cloudflareToken?allowed-host=api.cloudflare.com&request=token"},{"secretName":"slackToken","host":"slack.com","approvalUrl":"https://example.com/account/secrets/user/slackToken?allowed-host=slack.com&request=token"}]',
nextStep:
'Ask the user whether they want to approve these hosts for the listed secrets in the account web UI, then retry after approval.',
missingApprovals: [
{
secretName: 'cloudflareToken',
host: 'api.cloudflare.com',
approvalUrl:
'https://example.com/account/secrets/user/cloudflareToken?allowed-host=api.cloudflare.com&request=token',
},
{
secretName: 'slackToken',
host: 'slack.com',
approvalUrl:
'https://example.com/account/secrets/user/slackToken?allowed-host=slack.com&request=token',
},
],
suggestedAction: {
type: 'approve_secret_host',
},
})
})
Loading
Loading