diff --git a/packages/worker/client/routes/connect-secret-errors.node.test.ts b/packages/worker/client/routes/connect-secret-errors.node.test.ts new file mode 100644 index 0000000000..28fba3effc --- /dev/null +++ b/packages/worker/client/routes/connect-secret-errors.node.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from 'vitest' +import { formatConnectorConfigFailureMessage } from './connect-secret-errors.ts' + +test('reports rollback when secret rollback completed', () => { + const message = formatConnectorConfigFailureMessage( + new Error('Config update exploded.'), + { secretRolledBack: true }, + ) + + expect(message).toBe( + 'Connector configuration failed and the secret was rolled back. Config update exploded.', + ) +}) + +test('does not claim rollback when no connector rollback ran', () => { + const message = formatConnectorConfigFailureMessage( + new Error('Config update exploded.'), + { secretRolledBack: false }, + ) + + expect(message).toBe( + 'Connector configuration failed after the secret was saved. Config update exploded.', + ) +}) + +test('reports updated secret retained when connector config fails on existing secret', () => { + const message = formatConnectorConfigFailureMessage( + new Error('Config update exploded.'), + { secretRolledBack: false, updatedSecretRetained: true }, + ) + + expect(message).toBe( + 'Connector configuration failed after the secret was updated. Config update exploded.', + ) +}) diff --git a/packages/worker/client/routes/connect-secret-errors.ts b/packages/worker/client/routes/connect-secret-errors.ts new file mode 100644 index 0000000000..3bb9149d2e --- /dev/null +++ b/packages/worker/client/routes/connect-secret-errors.ts @@ -0,0 +1,18 @@ +export function formatConnectorConfigFailureMessage( + error: unknown, + options: { + secretRolledBack: boolean + updatedSecretRetained?: boolean + }, +) { + const message = + error instanceof Error + ? error.message + : 'Unable to update connector config.' + if (options.updatedSecretRetained) { + return `Connector configuration failed after the secret was updated. ${message}` + } + return options.secretRolledBack + ? `Connector configuration failed and the secret was rolled back. ${message}` + : `Connector configuration failed after the secret was saved. ${message}` +} diff --git a/packages/worker/client/routes/connect-secret.tsx b/packages/worker/client/routes/connect-secret.tsx new file mode 100644 index 0000000000..091a7c0370 --- /dev/null +++ b/packages/worker/client/routes/connect-secret.tsx @@ -0,0 +1,797 @@ +import { type Handle } from 'remix/component' +import { navigate } from '#client/client-router.tsx' +import { + colors, + radius, + shadows, + spacing, + typography, +} from '#client/styles/tokens.ts' +import { formatConnectorConfigFailureMessage } from './connect-secret-errors.ts' + +type StorageScope = 'app' | 'session' | 'user' +type ViewStep = + | 'loading' + | 'input' + | 'review' + | 'saving' + | 'success' + | 'error' + | 'update-confirm' + | 'cancelled' + +type SecretMetadata = { + name: string + scope: StorageScope + description: string + allowed_hosts: Array + allowed_capabilities: Array + created_at: string + updated_at: string + ttl_ms: number | null +} + +type ConnectSecretParams = { + name: string + description: string + allowedHosts: Array + allowedCapabilities: Array + scope: StorageScope + dashboardUrl: string + instructions: string + connector: string +} + +type ConnectSecretState = { + step: ViewStep + error: string + secretValue: string + existingSecret: SecretMetadata | null + updateConfirmed: boolean + confirmedReview: boolean +} + +type ConnectSecretSession = { + token: string + endpoints: { + secrets: string + deleteSecret: string + execute: string + source: string + } +} + +const defaultState: ConnectSecretState = { + step: 'loading', + error: '', + secretValue: '', + existingSecret: null, + updateConfirmed: false, + confirmedReview: false, +} + +function getSearchParams() { + return typeof window === 'undefined' + ? new URLSearchParams() + : new URLSearchParams(window.location.search) +} + +function parseScope(value: string | null): StorageScope { + return value === 'app' || value === 'session' || value === 'user' + ? value + : 'user' +} + +function parseCommaList(value: string | null, normalizer?: (item: string) => string) { + if (!value) return [] + const output = value + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + return Array.from(new Set(output.map((item) => normalizer?.(item) ?? item))) +} + +function parseConnectSecretParams(): ConnectSecretParams { + const params = getSearchParams() + const name = params.get('name')?.trim() ?? '' + const description = params.get('description')?.trim() ?? '' + const instructions = params.get('instructions')?.trim() ?? '' + const dashboardUrl = params.get('dashboardUrl')?.trim() ?? '' + const connector = params.get('connector')?.trim() ?? '' + const scope = parseScope(params.get('scope')) + const allowedHosts = parseCommaList(params.get('allowedHosts'), (value) => + value.toLowerCase(), + ).sort() + const allowedCapabilities = parseCommaList(params.get('allowedCapabilities')) + .sort((left, right) => left.localeCompare(right)) + return { + name, + description, + allowedHosts, + allowedCapabilities, + scope, + dashboardUrl, + instructions, + connector, + } +} + +function scopeLabel(scope: StorageScope) { + if (scope === 'session') return 'Session (expires when this session ends)' + if (scope === 'app') return 'App' + return 'User' +} + +function isSafeUrl(value: string) { + if (!value) return false + try { + const parsed = new URL(value) + return parsed.protocol === 'https:' || parsed.protocol === 'http:' + } catch { + return false + } +} + +async function readSessionToken() { + const url = new URL('/connect/secret.json', window.location.href) + url.search = window.location.search + const response = await fetch(url.toString(), { + headers: { Accept: 'application/json' }, + credentials: 'include', + }) + if (response.status === 401) { + navigate(`/login?redirectTo=${encodeURIComponent(window.location.pathname + window.location.search)}`) + return null + } + const payload = (await response.json().catch(() => null)) as { + ok?: boolean + appSession?: ConnectSecretSession + error?: string + } + if (!response.ok || !payload?.ok || !payload.appSession?.token) { + throw new Error(payload?.error || 'Unable to load secret session.') + } + return payload.appSession +} + +function isSessionRefreshError(error: unknown) { + return ( + error instanceof Error && + (error.message.includes('Generated UI session has expired.') || + error.message.includes('Invalid session token.') || + error.message.includes('User mismatch.')) + ) +} + +async function listExistingSecret( + params: ConnectSecretParams, + session: ConnectSecretSession, +): Promise { + const url = new URL(session.endpoints.secrets) + url.searchParams.set('scope', params.scope) + const response = await fetch(url.toString(), { + headers: { + Accept: 'application/json', + Authorization: `Bearer ${session.token}`, + }, + credentials: 'omit', + }) + const payload = (await response.json().catch(() => null)) as { + ok?: boolean + secrets?: Array + error?: string + } + if (!response.ok) { + throw new Error(payload?.error || 'Unable to load existing secrets.') + } + if (!payload?.ok || !Array.isArray(payload.secrets)) { + return null + } + return ( + payload.secrets.find( + (secret) => secret.name === params.name && secret.scope === params.scope, + ) ?? null + ) +} + +async function saveSecretValue( + params: ConnectSecretParams, + session: ConnectSecretSession, + value: string, +) { + const response = await fetch(session.endpoints.secrets, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${session.token}`, + }, + credentials: 'omit', + body: JSON.stringify({ + action: 'save', + name: params.name, + value, + description: params.description, + scope: params.scope, + }), + }) + const payload = (await response.json().catch(() => null)) as { + ok?: boolean + error?: string + } + if (!response.ok || !payload?.ok) { + throw new Error(payload?.error || 'Unable to save secret.') + } +} + +async function updateConnectorConfig( + params: ConnectSecretParams, + session: ConnectSecretSession, +) { + if (!params.connector) return + const response = await fetch('/connect/secret.json', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + name: params.name, + scope: params.scope, + sessionToken: session.token, + connector: params.connector, + allowedHosts: params.allowedHosts, + allowedCapabilities: params.allowedCapabilities, + }), + }) + const payload = (await response.json().catch(() => null)) as { + ok?: boolean + error?: string + } + if (!response.ok || !payload?.ok) { + throw new Error(payload?.error || 'Unable to update connector config.') + } +} + +async function rollbackSecretValue( + params: ConnectSecretParams, + session: ConnectSecretSession, +) { + const response = await fetch(session.endpoints.deleteSecret, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${session.token}`, + }, + credentials: 'omit', + body: JSON.stringify({ + name: params.name, + scope: params.scope, + }), + }) + const payload = (await response.json().catch(() => null)) as { + ok?: boolean + error?: string + } + if (!response.ok || !payload?.ok) { + throw new Error(payload?.error || 'Unable to rollback secret.') + } +} + +export function ConnectSecretRoute(handle: Handle) { + let state = { ...defaultState } + let session: ConnectSecretSession | null = null + let lastSearch: string | null = null + let initVersion = 0 + + function setState(next: Partial) { + state = { ...state, ...next } + handle.update() + } + + function startInitialization() { + session = null + initVersion += 1 + const version = initVersion + setState({ ...defaultState, step: 'loading' }) + handle.queueTask(() => initialize(version)) + } + + async function initialize(version: number) { + const params = parseConnectSecretParams() + if (!params.name) { + if (version !== initVersion) return + setState({ + step: 'error', + error: 'Provide a name query parameter to continue.', + }) + return + } + try { + const nextSession = await readSessionToken() + if (version !== initVersion || !nextSession) return + session = nextSession + const existing = await listExistingSecret(params, nextSession) + if (version !== initVersion) return + if (existing) { + setState({ + step: 'update-confirm', + existingSecret: existing, + }) + return + } + setState({ step: 'input' }) + } catch (error) { + if (version !== initVersion) return + if (isSessionRefreshError(error)) { + startInitialization() + return + } + setState({ + step: 'error', + error: error instanceof Error ? error.message : 'Unable to load secrets.', + }) + } + } + + async function handleSave() { + if (state.step === 'saving') return + if (!session) { + setState({ + step: 'error', + error: 'Missing session token. Refresh and retry.', + }) + return + } + const params = parseConnectSecretParams() + if (!state.secretValue.trim()) { + setState({ + step: 'error', + error: 'Enter the secret value before continuing.', + }) + return + } + setState({ step: 'saving', error: '' }) + try { + await saveSecretValue(params, session, state.secretValue) + } catch (error) { + if (isSessionRefreshError(error)) { + startInitialization() + return + } + setState({ + step: 'error', + error: error instanceof Error ? error.message : 'Unable to save secret.', + }) + return + } + try { + await updateConnectorConfig(params, session) + setState({ step: 'success' }) + } catch (error) { + const secretWasNewlyCreated = state.existingSecret == null + if (params.connector && secretWasNewlyCreated) { + try { + await rollbackSecretValue(params, session) + } catch (rollbackError) { + const rollbackMessage = + rollbackError instanceof Error + ? rollbackError.message + : 'Unable to rollback secret.' + const originalMessage = + error instanceof Error + ? error.message + : 'Unable to update connector config.' + setState({ + step: 'error', + error: `Connector configuration failed and rollback did not complete. ${originalMessage} ${rollbackMessage}`, + }) + return + } + } + setState({ + step: 'error', + error: formatConnectorConfigFailureMessage(error, { + secretRolledBack: Boolean(params.connector && secretWasNewlyCreated), + updatedSecretRetained: Boolean( + params.connector && !secretWasNewlyCreated + ), + }), + }) + } + } + + function handleErrorBack() { + if (!session || state.error.includes('session')) { + startInitialization() + return + } + setState({ step: 'input', error: '' }) + } + + return () => { + const currentSearch = typeof window === 'undefined' ? '' : window.location.search + if (currentSearch !== lastSearch) { + lastSearch = currentSearch + startInitialization() + } + + const params = parseConnectSecretParams() + const hasInstructions = Boolean(params.instructions || params.dashboardUrl) + const showReview = state.step === 'review' || state.step === 'saving' + + return ( +
+
+ + Kody secure connection + +

+ Save a secret +

+

+ {params.description || + 'This keeps credentials private and out of chat logs.'} +

+
+ + {state.step === 'loading' ? ( +

Loading secret details…

+ ) : null} + + {state.step === 'update-confirm' && state.existingSecret ? ( +
+

Secret already exists

+

+ A secret named {state.existingSecret.name} already + exists in the {state.existingSecret.scope} scope. Updating will + replace the stored value. +

+
+
+
Current allowed hosts
+
    + {state.existingSecret.allowed_hosts.length > 0 ? ( + state.existingSecret.allowed_hosts.map((host) => ( +
  • {host}
  • + )) + ) : ( +
  • None
  • + )} +
+
+
+
Current allowed capabilities
+
    + {state.existingSecret.allowed_capabilities.length > 0 ? ( + state.existingSecret.allowed_capabilities.map((cap) => ( +
  • {cap}
  • + )) + ) : ( +
  • None
  • + )} +
+
+
+
+ + +
+
+ ) : null} + + {state.step === 'cancelled' ? ( +
+

Cancelled

+

+ No changes were made. You can close this tab. +

+
+ ) : null} + + {state.step === 'success' ? ( +
+

Secret saved

+

+ You can close this tab now. +

+
+ ) : null} + + {state.step === 'error' ? ( +
+

Something went wrong

+

{state.error}

+ +
+ ) : null} + + {['input', 'review', 'saving'].includes(state.step) ? ( + <> +
+

Instructions

+ {hasInstructions ? ( + <> + {params.instructions ? ( +

+ {params.instructions} +

+ ) : null} + {params.dashboardUrl && isSafeUrl(params.dashboardUrl) ? ( + + Open provider settings + + ) : null} + {params.dashboardUrl && !isSafeUrl(params.dashboardUrl) ? ( +

+ The provided dashboard link is invalid. +

+ ) : null} + + ) : ( +

+ Enter the secret value below. +

+ )} +
+ +
+

Enter secret

+ +
+ + {showReview ? ( +
+

Review before saving

+
+
+
Secret name
+
{params.name}
+
+
+
Scope
+
{scopeLabel(params.scope)}
+
+ {params.description ? ( +
+
Description
+
{params.description}
+
+ ) : null} +
+
+
+
Hosts to approve
+
    + {params.allowedHosts.length > 0 ? ( + params.allowedHosts.map((host) => ( +
  • {host}
  • + )) + ) : ( +
  • None (approval required later).
  • + )} +
+
+
+
Capabilities to allow
+
    + {params.allowedCapabilities.length > 0 ? ( + params.allowedCapabilities.map((capability) => ( +
  • {capability}
  • + )) + ) : ( +
  • No restrictions requested.
  • + )} +
+
+
+

+ Host and capability approvals are managed in account settings. +

+

+ The secret value stays hidden and cannot be viewed later. +

+
+ ) : null} + + {showReview ? ( + + ) : null} + + {state.step === 'saving' ? ( +

Saving secret…

+ ) : null} + +
+ {showReview ? ( + <> + + + + ) : ( + + )} +
+ + ) : null} +
+ ) + } +} + +const cardCss = { + padding: spacing.lg, + borderRadius: radius.lg, + border: `1px solid ${colors.border}`, + backgroundColor: colors.surface, + boxShadow: shadows.sm, + display: 'grid', + gap: spacing.sm, +} + +const cardTitleCss = { + margin: 0, + fontSize: typography.fontSize.lg, + fontWeight: typography.fontWeight.semibold, + color: colors.text, +} + +const labelCss = { + fontWeight: typography.fontWeight.medium, + fontSize: typography.fontSize.sm, + color: colors.textMuted, +} + +const listCss = { + margin: 0, + paddingLeft: spacing.lg, + display: 'grid', + gap: spacing.xs, + color: colors.text, +} + +const inputCss = { + padding: spacing.sm, + borderRadius: radius.md, + border: `1px solid ${colors.border}`, + backgroundColor: colors.background, + color: colors.text, + fontFamily: typography.fontFamily, + fontSize: typography.fontSize.base, +} + +const primaryButtonCss = { + padding: `${spacing.sm} ${spacing.lg}`, + borderRadius: radius.full, + border: 'none', + backgroundColor: colors.primary, + color: colors.onPrimary, + fontWeight: typography.fontWeight.semibold, + cursor: 'pointer', +} + +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 f71d99c1bd..bbdc3052f9 100644 --- a/packages/worker/client/routes/index.tsx +++ b/packages/worker/client/routes/index.tsx @@ -1,6 +1,7 @@ import { AccountRoute } from './account.tsx' import { AccountSecretsRoute } from './account-secrets.tsx' import { ChatRoute } from './chat.tsx' +import { ConnectSecretRoute } from './connect-secret.tsx' import { HomeRoute } from './home.tsx' import { LoginRoute } from './login.tsx' import { OAuthAuthorizeRoute } from './oauth-authorize.tsx' @@ -13,6 +14,7 @@ export const clientRoutes = { '/chat': , '/chat/:threadId': , '/ui/:id': , + '/connect/secret': , '/account': , '/account/secrets': , '/account/secrets/new': , diff --git a/packages/worker/src/app/handlers/connect-secret.node.test.ts b/packages/worker/src/app/handlers/connect-secret.node.test.ts new file mode 100644 index 0000000000..cff799821b --- /dev/null +++ b/packages/worker/src/app/handlers/connect-secret.node.test.ts @@ -0,0 +1,188 @@ +import { expect, test, vi } from 'vitest' + +const mockModule = vi.hoisted(() => ({ + readAuthenticatedAppUser: async () => { + return { + sessionUserId: '42', + userId: 42, + email: 'user@example.com', + displayName: 'user', + artifactOwnerIds: ['owner-1'], + mcpUser: { + userId: 'stable-user-1', + email: 'user@example.com', + displayName: 'user', + }, + } + }, + getAppBaseUrl: () => 'https://example.com', + createGeneratedUiAppSession: async (input: { + appId?: string | null + }) => ({ + token: 'generated-token', + sessionId: 'session-1', + endpoints: { + secrets: 'https://example.com/ui-api/session-1/secrets', + deleteSecret: 'https://example.com/ui-api/session-1/secrets/delete', + execute: 'https://example.com/ui-api/session-1/execute', + source: 'https://example.com/ui-api/session-1/source', + }, + appId: input.appId ?? null, + }), + verifyGeneratedUiAppSession: async () => ({ + session_id: 'session-1', + app_id: null, + user: { userId: 'stable-user-1' }, + }), + resolveSecret: async () => ({ + found: true, + allowedHosts: [], + allowedCapabilities: [], + }), + saveValue: async () => undefined, + getUiArtifactByOwnerIds: async ( + _db: D1Database, + _owners: Array, + appId: string, + ) => + appId === 'app-123' + ? { + id: 'app-123', + } + : null, +})) + +vi.mock('#app/auth-session.ts', () => ({ + readAuthSessionResult: async () => ({ session: null, setCookie: null }), +})) + +vi.mock('#app/authenticated-user.ts', () => ({ + readAuthenticatedAppUser: (...args: Array) => + mockModule.readAuthenticatedAppUser(...args), +})) + +vi.mock('#app/auth-redirect.ts', () => ({ + redirectToLogin: () => new Response(null, { status: 302 }), +})) + +vi.mock('#app/app-base-url.ts', () => ({ + getAppBaseUrl: (...args: Array) => mockModule.getAppBaseUrl(...args), +})) + +vi.mock('#app/layout.ts', () => ({ + Layout: () => null, +})) + +vi.mock('#app/render.ts', () => ({ + render: () => new Response('ok'), +})) + +vi.mock('#mcp/generated-ui-app-session.ts', () => ({ + createGeneratedUiAppSession: (...args: Array) => + mockModule.createGeneratedUiAppSession(...args), + verifyGeneratedUiAppSession: (...args: Array) => + mockModule.verifyGeneratedUiAppSession(...args), +})) + +vi.mock('#mcp/capabilities/registry.ts', () => ({ + capabilityMap: { + linear_issue_list: true, + }, +})) + +vi.mock('#mcp/secrets/allowed-hosts.ts', () => ({ + normalizeAllowedHosts: (hosts: Array) => hosts, +})) + +vi.mock('#mcp/secrets/service.ts', () => ({ + resolveSecret: (...args: Array) => mockModule.resolveSecret(...args), +})) + +vi.mock('#mcp/values/service.ts', () => ({ + saveValue: (...args: Array) => mockModule.saveValue(...args), +})) + +vi.mock('#mcp/ui-artifacts-repo.ts', () => ({ + getUiArtifactByOwnerIds: (...args: Array) => + mockModule.getUiArtifactByOwnerIds(...args), +})) + +const { createConnectSecretApiHandler } = await import('./connect-secret.ts') + +function createEnv() { + return { + APP_DB: {} as D1Database, + COOKIE_SECRET: 'secret', + } as Env +} + +async function readJson(response: Response) { + return (await response.json()) as { ok?: boolean; error?: string } +} + +test('connect secret GET rejects app scope without appId', async () => { + const handler = createConnectSecretApiHandler(createEnv()) + const response = await handler.action({ + request: new Request('https://example.com/connect/secret.json?scope=app'), + params: {}, + } as never) + + expect(response.status).toBe(400) + await expect(readJson(response)).resolves.toEqual({ + ok: false, + error: 'App scope requires an appId query parameter.', + }) +}) + +test('connect secret GET rejects unknown scope values', async () => { + const handler = createConnectSecretApiHandler(createEnv()) + const response = await handler.action({ + request: new Request('https://example.com/connect/secret.json?scope=sessions'), + params: {}, + } as never) + + expect(response.status).toBe(400) + await expect(readJson(response)).resolves.toEqual({ + ok: false, + error: 'Invalid secret scope.', + }) +}) + +test('connect secret GET creates app-scoped session with requested app id', async () => { + const handler = createConnectSecretApiHandler(createEnv()) + const response = await handler.action({ + request: new Request( + 'https://example.com/connect/secret.json?scope=app&appId=app-123', + ), + params: {}, + } as never) + + expect(response.status).toBe(200) + await expect(readJson(response)).resolves.toMatchObject({ + ok: true, + }) +}) + +test('connect secret POST rejects app scope when session is not app-scoped', async () => { + const handler = createConnectSecretApiHandler(createEnv()) + const response = await handler.action({ + request: new Request('https://example.com/connect/secret.json', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'linearApiKey', + scope: 'app', + sessionToken: 'generated-token', + connector: 'linear', + allowedCapabilities: ['linear_issue_list'], + }), + }), + params: {}, + } as never) + + expect(response.status).toBe(400) + await expect(readJson(response)).resolves.toEqual({ + ok: false, + error: 'App scope requires an app-scoped session.', + }) +}) diff --git a/packages/worker/src/app/handlers/connect-secret.ts b/packages/worker/src/app/handlers/connect-secret.ts new file mode 100644 index 0000000000..27b3c989d5 --- /dev/null +++ b/packages/worker/src/app/handlers/connect-secret.ts @@ -0,0 +1,284 @@ +import { type BuildAction } from 'remix/fetch-router' +import { readAuthSessionResult } from '#app/auth-session.ts' +import { readAuthenticatedAppUser } from '#app/authenticated-user.ts' +import { redirectToLogin } from '#app/auth-redirect.ts' +import { getAppBaseUrl } from '#app/app-base-url.ts' +import { Layout } from '#app/layout.ts' +import { render } from '#app/render.ts' +import { type routes } from '#app/routes.ts' +import { + createGeneratedUiAppSession, + verifyGeneratedUiAppSession, +} from '#mcp/generated-ui-app-session.ts' +import { capabilityMap } from '#mcp/capabilities/registry.ts' +import { normalizeAllowedHosts } from '#mcp/secrets/allowed-hosts.ts' +import { resolveSecret } from '#mcp/secrets/service.ts' +import { secretScopeValues, type SecretScope } from '#mcp/secrets/types.ts' +import { saveValue } from '#mcp/values/service.ts' +import { getUiArtifactByOwnerIds } from '#mcp/ui-artifacts-repo.ts' + +export function createConnectSecretHandler(_env: Env) { + return { + middleware: [], + async action({ request }) { + const { session, setCookie } = await readAuthSessionResult(request) + if (!session) { + return redirectToLogin(request) + } + const response = render(Layout({ title: 'Connect secret' })) + if (setCookie) { + response.headers.set('Set-Cookie', setCookie) + } + return response + }, + } satisfies BuildAction< + typeof routes.connectSecret.method, + typeof routes.connectSecret.pattern + > +} + +export function createConnectSecretApiHandler(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 url = new URL(request.url) + const scope = readSecretScope(url) + if (!scope) { + return jsonResponse({ ok: false, error: 'Invalid secret scope.' }, 400) + } + const requestedAppId = readOptionalStringParam(url, 'appId') + if (scope === 'app' && !requestedAppId) { + return jsonResponse( + { + ok: false, + error: 'App scope requires an appId query parameter.', + }, + 400, + ) + } + const appArtifact = + scope === 'app' && requestedAppId + ? await getUiArtifactByOwnerIds( + env.APP_DB, + user.artifactOwnerIds, + requestedAppId, + ) + : null + if (scope === 'app' && !appArtifact) { + return jsonResponse({ ok: false, error: 'Saved app not found.' }, 404) + } + const appId = scope === 'app' ? (appArtifact?.id ?? null) : null + const baseUrl = getAppBaseUrl({ env, requestUrl: request.url }) + const appSession = await createGeneratedUiAppSession({ + env, + baseUrl, + user: user.mcpUser, + appId, + homeConnectorId: null, + }) + return jsonResponse({ + ok: true, + appSession: { + token: appSession.token, + sessionId: appSession.sessionId, + endpoints: appSession.endpoints, + }, + }) + } + + 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 name = readString(body, 'name') + const scope = readScope(body) + const sessionToken = readString(body, 'sessionToken') + const connector = readOptionalString(body, 'connector') + const requestedAllowedHosts = + readOptionalStringArray(body, 'allowedHosts') ?? [] + const requestedAllowedCapabilities = + readOptionalStringArray(body, 'allowedCapabilities') ?? [] + const unknownCapabilities = requestedAllowedCapabilities.filter( + (capability) => !capabilityMap[capability], + ) + if (unknownCapabilities.length > 0) { + return jsonResponse( + { + ok: false, + error: `Unknown capabilities: ${unknownCapabilities.join(', ')}`, + }, + 400, + ) + } + if (!name) { + return jsonResponse({ ok: false, error: 'Secret name is required.' }, 400) + } + if (!scope) { + return jsonResponse({ ok: false, error: 'Secret scope is required.' }, 400) + } + if (!sessionToken) { + return jsonResponse( + { ok: false, error: 'Session token is required.' }, + 400, + ) + } + + let session + try { + session = await verifyGeneratedUiAppSession(env, sessionToken) + } catch (error) { + return jsonResponse( + { + ok: false, + error: + error instanceof Error + ? error.message + : 'Invalid session token.', + }, + 401, + ) + } + if (session.user.userId !== user.mcpUser.userId) { + return jsonResponse({ ok: false, error: 'User mismatch.' }, 403) + } + const scopeCompatibilityError = validateConnectSecretSessionScope({ + requestedScope: scope, + sessionAppId: session.app_id ?? null, + }) + if (scopeCompatibilityError) { + return jsonResponse( + { ok: false, error: scopeCompatibilityError }, + 400, + ) + } + + const storageContext = { + sessionId: session.session_id, + appId: session.app_id ?? null, + } + + try { + if (connector) { + const resolved = await resolveSecret({ + env, + userId: user.mcpUser.userId, + name, + scope, + storageContext, + }) + if (!resolved.found) { + return jsonResponse({ ok: false, error: 'Secret not found.' }, 404) + } + const allowedHosts = + requestedAllowedHosts.length > 0 + ? normalizeAllowedHosts(requestedAllowedHosts) + : resolved.allowedHosts + const allowedCapabilities = + requestedAllowedCapabilities.length > 0 + ? requestedAllowedCapabilities + : resolved.allowedCapabilities + await saveValue({ + env, + userId: user.mcpUser.userId, + name: `_connector:${connector}`, + value: JSON.stringify({ + secretName: name, + allowedHosts, + allowedCapabilities, + }), + description: `Connector secret config for ${connector}`, + scope, + storageContext, + }) + } + return jsonResponse({ ok: true }) + } catch (error) { + return jsonResponse( + { + ok: false, + error: + error instanceof Error + ? error.message + : 'Unable to update connector configuration.', + }, + 400, + ) + } + }, + } satisfies BuildAction< + typeof routes.connectSecretApi.method, + typeof routes.connectSecretApi.pattern + > +} + +function readSecretScope(url: URL): SecretScope | null { + const raw = readOptionalStringParam(url, 'scope') + if (raw == null) return 'user' + return secretScopeValues.includes(raw as SecretScope) + ? (raw as SecretScope) + : null +} + +function validateConnectSecretSessionScope(input: { + requestedScope: SecretScope + sessionAppId: string | null +}) { + if (input.requestedScope === 'app') { + return input.sessionAppId + ? null + : 'App scope requires an app-scoped session.' + } + return input.sessionAppId + ? 'This connect secret session only supports app-scoped secrets.' + : null +} + +function readOptionalStringParam(url: URL, key: string) { + const value = url.searchParams.get(key) + return value?.trim() ? value.trim() : null +} + +function readString(body: object, key: string) { + const value = (body as Record)[key] + return typeof value === 'string' && value.trim() ? value.trim() : null +} + +function readOptionalString(body: object, key: string) { + const value = (body as Record)[key] + return typeof value === 'string' ? value.trim() : null +} + +function readOptionalStringArray(body: object, key: string) { + if (!Object.hasOwn(body, key)) return null + const value = (body as Record)[key] + if (!Array.isArray(value)) return [] + return value.filter((item): item is string => typeof item === 'string') +} + +function readScope(body: object): SecretScope | null { + const raw = readString(body, 'scope') + return raw && secretScopeValues.includes(raw as SecretScope) + ? (raw as SecretScope) + : 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/router.ts b/packages/worker/src/app/router.ts index 39f378d1f1..e737cc1bce 100644 --- a/packages/worker/src/app/router.ts +++ b/packages/worker/src/app/router.ts @@ -19,6 +19,10 @@ import { createPasswordResetConfirmHandler, createPasswordResetRequestHandler, } from '#app/handlers/password-reset.ts' +import { + createConnectSecretApiHandler, + createConnectSecretHandler, +} from '#app/handlers/connect-secret.ts' import { createSavedUiPageHandler } from '#app/handlers/saved-ui.ts' import { session } from '#app/handlers/session.ts' import { signup } from '#app/handlers/signup.ts' @@ -80,6 +84,12 @@ export function createAppRouter(appEnv: AppEnv) { routes.accountSecretsApiPost, createAccountSecretsApiHandler(appEnv as Env), ) + router.map(routes.connectSecret, createConnectSecretHandler(appEnv as Env)) + router.map(routes.connectSecretApi, createConnectSecretApiHandler(appEnv as Env)) + router.map( + routes.connectSecretApiPost, + createConnectSecretApiHandler(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 4ec4906367..9dc66ac470 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', + connectSecret: '/connect/secret', + connectSecretApi: '/connect/secret.json', + connectSecretApiPost: post('/connect/secret.json'), accountSecrets: '/account/secrets', accountSecretNew: '/account/secrets/new', accountSecretDetail: '/account/secrets/:secretId', diff --git a/packages/worker/src/mcp/capabilities/coding/domain.ts b/packages/worker/src/mcp/capabilities/coding/domain.ts index bfadecdb41..f58df32480 100644 --- a/packages/worker/src/mcp/capabilities/coding/domain.ts +++ b/packages/worker/src/mcp/capabilities/coding/domain.ts @@ -5,6 +5,7 @@ import { cloudflareRestCapability } from './cloudflare-rest.ts' import { cursorCloudAgentDocsCapability } from './cursor-cloud-agent-docs.ts' import { cursorCloudRestCapability } from './cursor-cloud-rest.ts' import { generatedUiOAuthGuideCapability } from './generated-ui-oauth-guide.ts' +import { generatedUiSecretGuideCapability } from './generated-ui-secret-guide.ts' import { githubGraphqlApiDocsCapability } from './github-graphql-api-docs.ts' import { githubGraphqlCapability } from './github-graphql.ts' import { githubRestApiDocsCapability } from './github-rest-api-docs.ts' @@ -20,6 +21,7 @@ export const codingDomain = defineDomain({ githubGraphqlCapability, githubGraphqlApiDocsCapability, generatedUiOAuthGuideCapability, + generatedUiSecretGuideCapability, cursorCloudRestCapability, cursorCloudAgentDocsCapability, cloudflareRestCapability, diff --git a/packages/worker/src/mcp/capabilities/coding/generated-ui-secret-guide.ts b/packages/worker/src/mcp/capabilities/coding/generated-ui-secret-guide.ts new file mode 100644 index 0000000000..1a98283ce4 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/coding/generated-ui-secret-guide.ts @@ -0,0 +1,110 @@ +import { z } from 'zod' +import { defineDomainCapability } from '../define-domain-capability.ts' +import { capabilityDomainNames } from '../domain-metadata.ts' +import { type CapabilityContext } from '../types.ts' + +const inputSchema = z + .object({}) + .describe( + 'No input. Returns the Kody guide for using the /connect/secret page to collect API keys and personal access tokens.', + ) + +const outputSchema = z.object({ + title: z.string().describe('Guide title.'), + body: z.string().describe('Markdown guidance for /connect/secret usage.'), +}) + +const guideBody = ` +# Connect secret guide + +Use the hosted **/connect/secret** page whenever the user needs to enter a +secret value such as an API key or personal access token. The agent must never +see the secret value. + +## When to use /connect/secret + +Use it when: + +- you need the user to provide a sensitive value +- a capability requires a secret placeholder that is missing +- rotating a stored secret value + +Do **not** ask the user to paste secrets into chat. + +## URL format + +Provide the user a URL like: + +\`\`\` +https://heykody.dev/connect/secret? + name=linearApiKey + &description=Linear API key for issue management + &allowedHosts=api.linear.app + &scope=user + &dashboardUrl=https://linear.app/settings/api + &instructions=Go to Linear Settings → API → Personal API Keys → Create key + &allowedCapabilities=linear_issue_list,linear_issue_create + &connector=linear +\`\`\` + +## Query params + +| Param | Required | Description | +| --- | --- | --- | +| \`name\` | yes | Secret name (e.g. \`linearApiKey\`). | +| \`description\` | no | Human-readable description shown in the UI. | +| \`allowedHosts\` | no | Comma-separated hosts to review for approval. | +| \`allowedCapabilities\` | no | Comma-separated capability names to review. Use only real Kody capability names (discoverable via search or meta_list_capabilities). | +| \`scope\` | no | \`user\` (default), \`session\`, or \`app\`. | +| \`appId\` | no | Required when \`scope=app\`. Use the saved UI app's real \`app_id\`. | +| \`dashboardUrl\` | no | Provider settings link for creating the key. | +| \`instructions\` | no | Step-by-step instructions shown on the page. | +| \`connector\` | no | Writes \`_connector:{connector}\` config on save. | + +## Approval policy reminders + +- Saving a secret does **not** approve outbound hosts. +- The connect page only shows the requested hosts/capabilities for review. +- Host and capability approvals must be handled in the authenticated account + secrets UI after the secret is saved. + +## Agent instructions + +1. Generate the URL with the required \`name\` and any optional params. + - When using \`scope=app\`, you must also include the saved app's real + \`appId\`. + - Only include \`allowedCapabilities\` when you have confirmed the capability + names exist in Kody (use \`search\` or \`meta_list_capabilities\`). +2. Ask the user to open the URL in their browser. +3. Wait until they confirm the secret is saved. +4. Proceed using \`{{secret:name}}\` placeholders or the relevant capability. +`.trim() + +export const generatedUiSecretGuideCapability = defineDomainCapability( + capabilityDomainNames.coding, + { + name: 'generated_ui_secret_guide', + description: + 'Read the guide for using the hosted /connect/secret page to collect API keys and personal access tokens safely.', + keywords: [ + 'secret', + 'api key', + 'personal access token', + 'connect secret', + 'generated ui', + 'hosted page', + 'credentials', + ], + readOnly: true, + idempotent: true, + destructive: false, + inputSchema, + outputSchema, + async handler(_args, _ctx: CapabilityContext) { + return { + title: 'Connect secret guide', + body: guideBody, + } + }, + }, +)