Skip to content
Closed
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 @@ -43,7 +43,7 @@ export type GeneratedUiRuntimeBootstrap = {
appSession?: GeneratedUiAppSessionBootstrap | null
}

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

export function buildGeneratedUiRuntimeImportMap(runtimeScriptHref: string) {
const importMapJson = escapeInlineScriptSource(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ export function getKodyWidget(): KodyWidgetPublicApi {
return widget
}
throw new Error(
'kodyWidget is not ready yet. Import `whenKodyWidgetReady()` from `@kody/utils` and await it before using the runtime helpers.',
'kodyWidget is not ready yet. Import `whenKodyWidgetReady()` from `@kody/ui-utils` and await it before using the runtime helpers.',
)
}

Expand Down
4 changes: 2 additions & 2 deletions packages/worker/src/app/saved-ui-hosted-html.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ test('renderHostedSavedUiHtml emits shared runtime assets for html apps', () =>
'<script type="module" src="https://kody.example/mcp-apps/generated-ui-runtime.js"></script>',
)
expect(result).toContain(
'<script type="importmap">{"imports":{"@kody/utils":"https://kody.example/mcp-apps/generated-ui-runtime.js"}}</script>',
'<script type="importmap">{"imports":{"@kody/ui-utils":"https://kody.example/mcp-apps/generated-ui-runtime.js"}}</script>',
)
expect(result).toContain(
'window.__kodyGeneratedUiBootstrap = {"mode":"hosted","appSession":{"token":"token-123","endpoints":{"source":"https://kody.example/ui-api/session-123/source","execute":"https://kody.example/ui-api/session-123/execute","secrets":"https://kody.example/ui-api/session-123/secrets","deleteSecret":"https://kody.example/ui-api/session-123/secrets/delete"}}};',
Expand Down Expand Up @@ -69,7 +69,7 @@ test('renderHostedSavedUiHtml keeps user javascript separate from runtime bootst
'<script type="module" src="https://kody.example/mcp-apps/generated-ui-runtime.js"></script>',
)
expect(result).toContain(
'<script type="importmap">{"imports":{"@kody/utils":"https://kody.example/mcp-apps/generated-ui-runtime.js"}}</script>',
'<script type="importmap">{"imports":{"@kody/ui-utils":"https://kody.example/mcp-apps/generated-ui-runtime.js"}}</script>',
)
expect(result).toContain(
'<script type="module">\ndocument.querySelector("[data-generated-ui-root]")?.append("hello")',
Expand Down
2 changes: 1 addition & 1 deletion packages/worker/src/mcp/capabilities/apps/ui-save-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const inputSchema = z.object({
.array(uiArtifactParameterSchema)
.optional()
.describe(
'Optional parameter definitions for reusable saved apps. Resolved values are exposed at runtime on the imported `kodyWidget.params` helper from `@kody/utils`.',
'Optional parameter definitions for reusable saved apps. Resolved values are exposed at runtime on the imported `kodyWidget.params` helper from `@kody/ui-utils`.',
),
})

Expand Down
2 changes: 1 addition & 1 deletion packages/worker/src/mcp/capabilities/apps/ui-update-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const inputSchema = z
.array(uiArtifactParameterSchema)
.optional()
.describe(
'Optional parameter definitions for reusable saved apps. Resolved values are exposed at runtime on the imported `kodyWidget.params` API from `@kody/utils`.',
'Optional parameter definitions for reusable saved apps. Resolved values are exposed at runtime on the imported `kodyWidget.params` API from `@kody/ui-utils`.',
),
})
.refine(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,7 @@ test('generated_ui_oauth_guide distinguishes PKCE and server-side exchange helpe
)
expect(result.body).toContain('`exchangeOAuthCodeWithSecrets(...)`')
expect(result.body).toContain('run server-side')
expect(result.body).toContain("import { kodyWidget } from '@kody/ui-utils'")
expect(result.body).not.toContain('@kody/utils')
expect(result.body).not.toContain('exchangeOAuthCode({ tokenUrl')
})
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ of treating it like a secret.
4. Give the user the hosted saved-app URL and tell them to open it in their
browser instead of trying to complete the flow in the conversation iframe.
5. In the hosted generated UI, import \`kodyWidget\` from
\`@kody/utils\` and use it directly to read the callback, validate state,
\`@kody/ui-utils\` and use it directly to read the callback, validate state,
exchange the code in the browser, and save tokens.

## Generated UI helpers to use
Expand Down Expand Up @@ -105,7 +105,7 @@ before building the authorization URL or token request.
\`\`\`html
<p id="status"></p>
<script type="module">
import { kodyWidget } from '@kody/utils'
import { kodyWidget } from '@kody/ui-utils'

async function requireClientId() {
const clientIdRecord = await kodyWidget.getValue({
Expand Down Expand Up @@ -188,7 +188,7 @@ authorization flow.
<button type="submit">Save and continue</button>
</form>
<script type="module">
import { kodyWidget } from '@kody/utils'
import { kodyWidget } from '@kody/ui-utils'

document.querySelector('#oauth-client-form')?.addEventListener('submit', async (event) => {
event.preventDefault()
Expand Down Expand Up @@ -240,7 +240,7 @@ Create and persist the OAuth state before redirecting the browser.
\`\`\`html
<button id="connect-muffin-club">Connect Muffin Club</button>
<script type="module">
import { kodyWidget } from '@kody/utils'
import { kodyWidget } from '@kody/ui-utils'

document.querySelector('#connect-muffin-club')?.addEventListener('click', async () => {
const clientIdRecord = await kodyWidget.getValue({
Expand Down Expand Up @@ -340,7 +340,7 @@ succeeds.
root.innerHTML = '<p>' + message + '</p>'
}

import { kodyWidget } from '@kody/utils'
import { kodyWidget } from '@kody/ui-utils'

void (async () => {
const callback = kodyWidget.readOAuthCallback({
Expand Down Expand Up @@ -427,7 +427,7 @@ export const generatedUiOAuthGuideCapability = defineDomainCapability(
'provider registration',
'ui_save_app',
'open_generated_ui',
'@kody/utils',
'@kody/ui-utils',
],
readOnly: true,
idempotent: true,
Expand Down
225 changes: 224 additions & 1 deletion packages/worker/src/mcp/executor.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,230 @@
createHostSecretAccessDeniedBatchMessage,
createMissingSecretMessage,
} from '#mcp/secrets/errors.ts'
import { formatExecutionOutput, getExecutionErrorDetails } from './executor.ts'
import {
createExecuteExecutor,
formatExecutionOutput,
getExecutionErrorDetails,
} from './executor.ts'

function createTestLoader(): WorkerLoader {
return {
get(_name, getCode) {
return {
getEntrypoint() {
return {
async evaluate(dispatchers: Record<string, { call: (name: string, argsJson: string) => Promise<string> }>) {
const workerCode = await getCode()
const modules = Object.fromEntries(
Object.entries(workerCode.modules).map(([key, value]) => [
key,
typeof value === 'string' ? value : (value.js ?? ''),
]),
)
const source = modules['executor.js']
if (!source) {
throw new Error('Missing executor.js module.')
}
const moduleCache = new Map<string, Promise<unknown>>()
const loadModule = async (specifier: string): Promise<unknown> => {
const cached = moduleCache.get(specifier)
if (cached) return cached
const moduleSource = modules[specifier]
if (!moduleSource) {
throw new Error(`Unknown module: ${specifier}`)
}
const modulePromise = (async () => {
const exports: Record<string, unknown> = Object.create(null)
const exportNames = Array.from(
moduleSource.matchAll(
/export\s+(?:async\s+function|function|const|class)\s+([A-Za-z_$][A-Za-z0-9_$]*)/g,
),
).map((match) => match[1]!)
const transformed = moduleSource
.replace(
/import\s+\{\s*([^}]+)\s*\}\s+from\s+["']([^"']+)["'];?/g,
(_match, names, importSpecifier) =>
`const { ${names.trim()} } = await __importModule(${JSON.stringify(importSpecifier)});`,
)
.replace(
/import\s+\*\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)\s+from\s+["']([^"']+)["'];?/g,
(_match, name, importSpecifier) =>
`const ${name} = await __importModule(${JSON.stringify(importSpecifier)});`,
)
.replace(
/export default class\s+([A-Za-z_$][A-Za-z0-9_$]*)/g,
'class $1',
)
.replace(
/export default async \(\)\s*=>/g,
'exports.default = async () =>',
)
.replace(
/export default /g,
'exports.default = ',
)
.replace(
/export async function\s+([A-Za-z_$][A-Za-z0-9_$]*)/g,
'exports.$1 = async function $1',
)
.replace(
/export function\s+([A-Za-z_$][A-Za-z0-9_$]*)/g,
'exports.$1 = function $1',
)
.replace(
/export const\s+([A-Za-z_$][A-Za-z0-9_$]*)/g,
'exports.$1 =',
)
const evaluator = new Function(
'exports',
'__importModule',
`return (async () => { ${transformed}\n${exportNames
.map((name) => `exports.${name} = ${name};`)
.join('\n')} })();`,
)
await evaluator(exports, loadModule)
return exports
})()
moduleCache.set(specifier, modulePromise)
return await modulePromise
}
const cloudflareWorkersExports = {
WorkerEntrypoint: class {
constructor(
readonly ctx: { props?: unknown } = {},
readonly env: unknown = {},
) {}
},
}
const mainModule = source.replace(
/import\s+\{\s*WorkerEntrypoint\s*\}\s+from\s+"cloudflare:workers";?/g,
'const { WorkerEntrypoint } = __cloudflareWorkers;',
)
const transformed = mainModule
.replace(
/export default class\s+([A-Za-z_$][A-Za-z0-9_$]*)/g,
'class $1',
)
.replace(
/import\s+\{\s*([^}]+)\s*\}\s+from\s+"([^"]+)";?/g,
(_match, names, importSpecifier) =>
`const { ${names.trim()} } = await __importModule(${JSON.stringify(importSpecifier)});`,
)
const exportsObject: Record<string, unknown> = Object.create(null)
const evaluator = new Function(
'exports',
'__dispatchers',
'__importModule',
'__cloudflareWorkers',
`return (async () => { ${transformed}
if (typeof CodeExecutor !== 'undefined') exports.default = CodeExecutor;
return exports.default; })();`,
)
const EntrypointClass = (await evaluator(
exportsObject,
dispatchers,
loadModule,
cloudflareWorkersExports,
)) as {
new (
ctx: { props?: unknown },
env: unknown,
): { evaluate(dispatchers: Record<string, unknown>): Promise<unknown> }
}
const instance = new EntrypointClass({}, {})
return instance.evaluate(dispatchers)
},
}
},
}
},
}
}

test('createExecuteExecutor returns results for async arrow execute code', async () => {
const executor = createExecuteExecutor({
env: {
LOADER: createTestLoader(),
} as Env,
exports: {
CodemodeFetchGateway() {
return {} as Fetcher
},
} as typeof import('cloudflare:workers').exports,

Check warning on line 157 in packages/worker/src/mcp/executor.node.test.ts

View workflow job for this annotation

GitHub Actions / 🧹 Lint

typescript-eslint(consistent-type-imports)

`import()` type annotations are forbidden.
gatewayProps: {
baseUrl: 'https://kody.example',
userId: 'user-123',
storageContext: null,
},
})

const result = await executor.execute(
`async () =>
await codemode.ui_save_app({
title: 'Execute generated app',
description: 'Saved through execute.',
keywords: ['execute', 'ui'],
code: '<main><h1>Execute App</h1></main>',
})`,
[
{
name: 'codemode',
fns: {
async ui_save_app() {
return {
app_id: 'app-123',
hosted_url: 'https://kody.example/ui/app-123',
}
},
},
},
],
)

expect(result).toEqual({
result: {
app_id: 'app-123',
hosted_url: 'https://kody.example/ui/app-123',
},
logs: [],
})
})

test('createExecuteExecutor rejects top-level import in execute code', async () => {
const executor = createExecuteExecutor({
env: {
LOADER: createTestLoader(),
} as Env,
exports: {
CodemodeFetchGateway() {
return {} as Fetcher
},
} as typeof import('cloudflare:workers').exports,

Check warning on line 206 in packages/worker/src/mcp/executor.node.test.ts

View workflow job for this annotation

GitHub Actions / 🧹 Lint

typescript-eslint(consistent-type-imports)

`import()` type annotations are forbidden.
gatewayProps: {
baseUrl: 'https://kody.example',
userId: 'user-123',
storageContext: null,
},
})

const result = await executor.execute(
`// import should not trigger module mode by itself
async () => ({ ok: true })`,
[
{
name: 'codemode',
fns: {},
},
],
)

expect(result).toEqual({
result: {
ok: true,
},
logs: [],
})
})

test('getExecutionErrorDetails returns concrete guidance for capability access denial', () => {
const error = new Error(
Expand Down
Loading
Loading