diff --git a/src/api-token.ts b/src/api-token.ts index 3d63827..ed0ad94 100644 --- a/src/api-token.ts +++ b/src/api-token.ts @@ -128,6 +128,21 @@ export function apiTokenMintInstructions(): string { return `For CI/headless only: mint with the Kody MCP \`api\` tool \`tokenCreate\` (include \`org:execute\` plus the capability scopes this command needs, e.g. \`package:execute\` / \`integration:read\`) and pass --token or set ${apiTokenEnvVar}.` } +/** + * CapabilityProxy refused the token outright (not a missing scope): one line + * saying so, then one line with the command that fixes it. + */ +export function capabilityProxyForbiddenMessage(input: { + code: string | null + serverMessage?: string | null +}): string { + const reason = input.serverMessage ? `: ${input.serverMessage}` : '' + return ( + `The API token is not allowed to use CapabilityProxy (${input.code ?? 'forbidden'})${reason}.\n` + + `Fix: run \`npx @kodycodes/cli login\` (agents on Kody MCP: ${cliBootstrapInstructions()}; CI: tokenCreate with \`org:execute\`, passed via --token or ${apiTokenEnvVar}).` + ) +} + /** Preferred interactive path for agents already on Kody MCP (ADR 0056). */ export function cliBootstrapInstructions( lifetime: CliTokenLifetimeAlias = 'short', diff --git a/src/capability-proxy.ts b/src/capability-proxy.ts index 05f2be8..7652a5c 100644 --- a/src/capability-proxy.ts +++ b/src/capability-proxy.ts @@ -2,8 +2,7 @@ import type { Agent as HttpAgent, IncomingMessage, request as httpRequest } from import type { Socket } from 'node:net' import { Readable } from 'node:stream' import { - apiTokenMintInstructions, - cliBootstrapInstructions, + capabilityProxyForbiddenMessage, currentStoredTokenLifetimeAlias, featureDisabledMessage, insufficientScopeMessage, @@ -678,7 +677,7 @@ function describeFailure( } if (status === 403 && stage === 'session') { return new CapabilityProxyError( - `The API token is not allowed to use CapabilityProxy${code ? ` (${code})` : ''}. ${cliBootstrapInstructions()} (lifetime short|long). ${apiTokenMintInstructions()}${detail}`, + capabilityProxyForbiddenMessage({ code, serverMessage: failure?.message }), { status, code }, ) } diff --git a/src/dev-command.ts b/src/dev-command.ts index 097f522..c87b8db 100644 --- a/src/dev-command.ts +++ b/src/dev-command.ts @@ -319,30 +319,37 @@ export async function runDevFetch(input: { json?: boolean io: DevIo }): Promise { - const manifest = await loadPackageManifest(input.cwd) - const server = await startPackageAppDev({ - manifest, - token: input.auth.token, - apiUrl: input.auth.apiUrl, - target: input.target, - port: input.port, - appBasePath: input.appBasePath, - ...(input.auth.org ? { org: input.auth.org } : {}), - ...(input.auth.depResolve ? { depResolve: input.auth.depResolve } : {}), - ...(input.auth.passthroughHosts?.length - ? { passthroughHosts: input.auth.passthroughHosts } - : {}), - allowPrivateNetwork: input.allowPrivateNetwork, - watch: false, - onStatus: (message) => input.io.stderr(`${message}\n`), - onError: (message) => input.io.stderr(`${message}\n`), - onEvent: (event) => { - // stdout is reserved for the fetch result; logs and errors go to stderr. - if (event.type === 'log' || event.type === 'runtime_error' || event.type === 'build_error') { - input.io.stderr(`${formatDevEvent(event).text}\n`) - } - }, - }) + let server: PackageAppDevServer + try { + const manifest = await loadPackageManifest(input.cwd) + server = await startPackageAppDev({ + manifest, + token: input.auth.token, + apiUrl: input.auth.apiUrl, + target: input.target, + port: input.port, + appBasePath: input.appBasePath, + ...(input.auth.org ? { org: input.auth.org } : {}), + ...(input.auth.depResolve ? { depResolve: input.auth.depResolve } : {}), + ...(input.auth.passthroughHosts?.length + ? { passthroughHosts: input.auth.passthroughHosts } + : {}), + allowPrivateNetwork: input.allowPrivateNetwork, + watch: false, + onStatus: (message) => input.io.stderr(`${message}\n`), + onError: (message) => input.io.stderr(`${message}\n`), + onEvent: (event) => { + // stdout is reserved for the fetch result; logs and errors go to stderr. + // Without a watcher a build error rejects startup, and the CLI prints that. + if (event.type === 'log' || event.type === 'runtime_error') { + input.io.stderr(`${formatDevEvent(event).text}\n`) + } + }, + }) + } catch (error) { + await input.auth.closeMock?.() + throw error + } try { const result = await server.fetchPath(input.path, { method: input.method, diff --git a/src/dev/dev-events.ts b/src/dev/dev-events.ts index 0b24314..e386b67 100644 --- a/src/dev/dev-events.ts +++ b/src/dev/dev-events.ts @@ -28,6 +28,8 @@ export type DevEvent = message: string stack: string request: DevRequestTag | null + /** Present when the error has a known, actionable cause. */ + fix?: string } | { type: 'log' @@ -75,6 +77,22 @@ function readRequestTag(value: unknown): DevRequestTag | null { } } +/** + * A fix line for runtime errors with a known cause, or undefined. esbuild's ESM + * output turns a CommonJS `require()` of an external (a Node builtin) into a + * shim that throws in Workers — hosted apps fail the same way. + */ +export function runtimeErrorFix(message: string): string | undefined { + const dynamicRequire = /Dynamic require of "([^"]+)" is not supported/.exec(message) + if (dynamicRequire) { + return ( + `A CommonJS dependency calls require("${dynamicRequire[1]}"), which an ESM Worker cannot run (hosted apps fail the same way). ` + + 'It likely resolved to its Node build: import the package’s Workers/edge entry (an exports subpath or condition like `edge`, `worker`, or `workerd`) or an ESM build instead.' + ) + } + return undefined +} + export function formatRequestTag(request: DevRequestTag | null): string { return request ? `[${request.method} ${request.path} #${request.id}]` : '[app]' } @@ -102,7 +120,7 @@ export function formatDevEvent(event: DevEvent): { stream: 'stdout' | 'stderr'; case 'runtime_error': return { stream: 'stderr', - text: `${formatRequestTag(event.request)} Uncaught ${event.stack || event.message}`, + text: `${formatRequestTag(event.request)} Uncaught ${event.stack || event.message}${event.fix ? `\nfix: ${event.fix}` : ''}`, } case 'log': { const tag = event.source === 'app' ? formatRequestTag(event.request) : `[${event.source}]` diff --git a/src/dev/dev-registry.ts b/src/dev/dev-registry.ts index 1b25adf..ec3c166 100644 --- a/src/dev/dev-registry.ts +++ b/src/dev/dev-registry.ts @@ -50,6 +50,7 @@ export type DevServerStatus = { message: string stack: string request: { id: string; method: string; path: string } | null + fix?: string at: string } | null diff --git a/src/local-bundle.ts b/src/local-bundle.ts index 6857884..fd809e5 100644 --- a/src/local-bundle.ts +++ b/src/local-bundle.ts @@ -22,10 +22,77 @@ export class BundleError extends Error { } } +/** + * Node builtins workerd resolves under `nodejs_compat` without the `node:` + * prefix. Must match the hosted publish list + * (`nodeBuiltinPackageNames` in the worker's bare-package-import-declarations), + * so a dependency that does `require('stream')` bundles locally exactly when it + * publishes. + */ +export const nodeBuiltinModuleNames = [ + '_http_agent', + '_http_client', + '_http_common', + '_http_incoming', + '_http_outgoing', + '_http_server', + '_stream_duplex', + '_stream_passthrough', + '_stream_readable', + '_stream_transform', + '_stream_wrap', + '_stream_writable', + '_tls_common', + '_tls_wrap', + 'assert', + 'async_hooks', + 'buffer', + 'child_process', + 'cluster', + 'console', + 'constants', + 'crypto', + 'dgram', + 'diagnostics_channel', + 'dns', + 'domain', + 'events', + 'fs', + 'http', + 'http2', + 'https', + 'inspector', + 'module', + 'net', + 'os', + 'path', + 'perf_hooks', + 'process', + 'punycode', + 'querystring', + 'readline', + 'repl', + 'stream', + 'string_decoder', + 'sys', + 'timers', + 'tls', + 'trace_events', + 'tty', + 'url', + 'util', + 'v8', + 'vm', + 'wasi', + 'worker_threads', + 'zlib', +] as const + /** * esbuild options shared by one-shot bundles and `kody dev` rebuilds: follow * relative imports and npm packages, keep `kody:*` external for package-graph - * + runtime binding, and leave `node:*` external for workerd `nodejs_compat`. + * + runtime binding, and leave Node builtins (`node:*` and bare `stream`, + * `fs/promises`, …) external for workerd `nodejs_compat`. */ export function localBundleOptions(entry: string) { return { @@ -38,7 +105,11 @@ export function localBundleOptions(entry: string) { target: 'esnext', logLevel: 'silent', packages: 'bundle', - external: ['kody:*', 'node:*'], + external: [ + 'kody:*', + 'node:*', + ...nodeBuiltinModuleNames.flatMap((name) => [name, `${name}/*`]), + ], } satisfies esbuild.BuildOptions } diff --git a/src/local-package-graph.ts b/src/local-package-graph.ts index 703eb40..655be8a 100644 --- a/src/local-package-graph.ts +++ b/src/local-package-graph.ts @@ -6,8 +6,7 @@ import { } from './capability-proxy.js' import { localWorkerModuleNames } from './local-runtime-source.js' import { - apiTokenMintInstructions, - cliBootstrapInstructions, + capabilityProxyForbiddenMessage, currentStoredTokenLifetimeAlias, featureDisabledMessage, insufficientScopeMessage, @@ -478,7 +477,7 @@ function describePackageGraphFailure( } if (status === 403) { return new LocalPackageGraphError( - `The API token is not allowed to use CapabilityProxy${code ? ` (${code})` : ''}. ${cliBootstrapInstructions()} (lifetime short|long). ${apiTokenMintInstructions()}${detail}`, + capabilityProxyForbiddenMessage({ code, serverMessage: failure?.message }), { status, code }, ) } diff --git a/src/local-runtime-bridge.ts b/src/local-runtime-bridge.ts index b1a49f6..a784484 100644 --- a/src/local-runtime-bridge.ts +++ b/src/local-runtime-bridge.ts @@ -71,7 +71,10 @@ export async function startLocalRuntimeBridge( ) } if (request.headers[runSecretHeader] !== input.runSecret) { - replyJson(403, { error: 'Forbidden' }) + replyJson(403, { + error: + 'Forbidden: missing run secret. Only the workerd started by this kody process may call its bridge.', + }) return } if (request.method === 'POST' && request.url === localBridgeCallPath) { @@ -108,7 +111,9 @@ export async function startLocalRuntimeBridge( } return } - replyJson(403, { error: 'Forbidden' }) + replyJson(404, { + error: 'Not found: the kody bridge only accepts POST /call and POST /gateway-fetch.', + }) }) await new Promise((resolve, reject) => { server.once('error', reject) diff --git a/src/local-runtime-source.ts b/src/local-runtime-source.ts index 6abe371..6e44afc 100644 --- a/src/local-runtime-source.ts +++ b/src/local-runtime-source.ts @@ -445,7 +445,7 @@ export default { async fetch(request, env) { const secret = env.${runSecretEnvVar}; if (__kodyClaimed || !secret || request.headers.get(__kodyRunSecretHeader) !== secret) { - return new Response('Forbidden', { status: 403 }); + return new Response('Forbidden: missing run secret, or this one-shot run was already claimed. Only the kody process that started this workerd may call it.', { status: 403 }); } __kodyClaimed = true; const { params } = await request.json(); diff --git a/src/package-app-dev.ts b/src/package-app-dev.ts index 71b4c4a..5731253 100644 --- a/src/package-app-dev.ts +++ b/src/package-app-dev.ts @@ -40,7 +40,12 @@ import { type LocalRuntimeBridge, } from './local-runtime-bridge.js' import { createDevBundler, type DevBundle } from './dev/dev-bundler.js' -import { parseWorkerdStdoutLine, type DevEvent, type DevRequestTag } from './dev/dev-events.js' +import { + parseWorkerdStdoutLine, + runtimeErrorFix, + type DevEvent, + type DevRequestTag, +} from './dev/dev-events.js' import { devErrorHeader, devRequestIdHeader } from './dev/dev-protocol.js' import { listenDevServer } from './dev/dev-port.js' import { devStatusPath, type DevServerStatus } from './dev/dev-registry.js' @@ -846,18 +851,26 @@ export async function startPackageAppDev( stack = raw } const mapped = redact(generation.mapStack(stack)) + const fix = runtimeErrorFix(message) lastRuntimeError = { kind: 'runtime', message: redact(message), stack: mapped, request: tag, + ...(fix ? { fix } : {}), at: new Date().toISOString(), } - emit({ type: 'runtime_error', message: redact(message), stack: mapped, request: tag }) + emit({ + type: 'runtime_error', + message: redact(message), + stack: mapped, + request: tag, + ...(fix ? { fix } : {}), + }) const view: DevErrorView = { kind: 'runtime', title: redact(message), - detail: mapped, + detail: fix ? `${mapped}\n\nfix: ${fix}` : mapped, request: `${tag.method} ${tag.path}`, } liveReloadHub.broadcast({ event: 'runtime_error', data: view }) diff --git a/src/package-local-runtime-source.ts b/src/package-local-runtime-source.ts index 2b06f6d..f2269e2 100644 --- a/src/package-local-runtime-source.ts +++ b/src/package-local-runtime-source.ts @@ -317,7 +317,7 @@ export default { async fetch(request, env) { const secret = env.${runSecretEnvVar}; if (__kodyClaimed || !secret || request.headers.get(__kodyRunSecretHeader) !== secret) { - return new Response('Forbidden', { status: 403 }); + return new Response('Forbidden: missing run secret, or this one-shot run was already claimed. Only the kody process that started this workerd may call it.', { status: 403 }); } __kodyClaimed = true; const { params } = await request.json(); @@ -457,7 +457,10 @@ export default { async fetch(request, env) { const secret = env.${runSecretEnvVar}; if (!secret || request.headers.get(__kodyRunSecretHeader) !== secret) { - return new Response('Forbidden', { status: 403 }); + return new Response( + 'Forbidden: missing dev run secret. Open the URL kody dev printed; this internal workerd port only answers its proxy.', + { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8' } }, + ); } __kodyInstallLocalBridge({ call: async (path, args) => { diff --git a/test/capability-proxy.test.ts b/test/capability-proxy.test.ts index 0ee88ec..0f193ee 100644 --- a/test/capability-proxy.test.ts +++ b/test/capability-proxy.test.ts @@ -105,6 +105,28 @@ test('openCapabilityProxySession names the local-execute flag when it is off', a ) }) +test('openCapabilityProxySession explains a refused token in one line plus a fix command', async () => { + const { fetchFn } = respondWith(403, { + error: { code: 'forbidden', message: 'token cannot execute' }, + }) + await assert.rejects( + () => openCapabilityProxySession({ apiUrl: 'https://api.kody.codes', token, fetchFn }), + (error: Error) => { + const lines = error.message.split('\n') + assert.equal(lines.length, 2) + assert.equal( + lines[0], + 'The API token is not allowed to use CapabilityProxy (forbidden): token cannot execute.', + ) + assert.match(lines[1] ?? '', /^Fix: run `npx @kodycodes\/cli login`/) + assert.match(lines[1] ?? '', /cliCredentialBootstrap/) + assert.match(lines[1] ?? '', /auth bootstrap --code --lifetime short/) + assert.match(lines[1] ?? '', /KODY_API_TOKEN/) + return true + }, + ) +}) + test('openCapabilityProxySession names insufficient_scope and the required scope', async () => { const { fetchFn } = respondWith(403, { error: { diff --git a/test/dev-server.test.ts b/test/dev-server.test.ts index 037c171..25da4ff 100644 --- a/test/dev-server.test.ts +++ b/test/dev-server.test.ts @@ -5,7 +5,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' import psList from 'ps-list' -import { formatDevEvent, parseWorkerdStdoutLine } from '../src/dev/dev-events.js' +import { + formatDevEvent, + parseWorkerdStdoutLine, + runtimeErrorFix, +} from '../src/dev/dev-events.js' import { appLogMarker } from '../src/dev/dev-protocol.js' import { shouldIgnoreWatchPath } from '../src/dev/dev-watcher.js' import { @@ -186,6 +190,23 @@ test('parseWorkerdStdoutLine reads tagged app logs and passes plain lines throug }) }) +test('runtime errors from a CommonJS require() of a Node builtin carry a fix line', () => { + const message = 'Error: Dynamic require of "util" is not supported' + const fix = runtimeErrorFix(message) + assert.match(fix ?? '', /require\("util"\)/) + assert.match(fix ?? '', /edge/) + assert.equal(runtimeErrorFix('Error: boom'), undefined) + const text = formatDevEvent({ + type: 'runtime_error', + message, + stack: message, + request: { id: '1', method: 'GET', path: '/' }, + fix, + }).text + assert.match(text, /^\[GET \/ #1\] Uncaught Error: Dynamic require/) + assert.match(text, /\nfix: A CommonJS dependency/) +}) + test('shouldIgnoreWatchPath skips dependencies, dotfiles, maps, and custom prefixes', () => { assert.equal(shouldIgnoreWatchPath('app/entry.ts'), false) assert.equal(shouldIgnoreWatchPath('node_modules/x/index.js'), true) diff --git a/test/kody-dev.test.ts b/test/kody-dev.test.ts index 54b28b3..1e05c65 100644 --- a/test/kody-dev.test.ts +++ b/test/kody-dev.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -196,6 +196,58 @@ test('kody dev job / webhook / emit / fetch against mock', async () => { } }) +test('kody dev fetch rejects on a bundle error, prints it once, and closes the mock', async () => { + const root = await mkdtemp(join(tmpdir(), 'kody-dev-broken-')) + try { + await writeFile( + join(root, 'package.json'), + JSON.stringify({ + name: '@local/broken-dev-package', + type: 'module', + kody: { id: 'broken-dev-package', app: { entry: './app/entry.ts' } }, + }), + ) + await mkdir(join(root, 'app')) + await writeFile( + join(root, 'app/entry.ts'), + 'export default { fetch() { return new Response("x" }\n', + ) + let stderr = '' + let stdout = '' + let mockClosed = 0 + const started = Date.now() + await assert.rejects( + runDevFetch({ + target: 'mock', + auth: { + token: 'mock', + apiUrl: 'http://127.0.0.1:9', + closeMock: async () => { + mockClosed += 1 + }, + }, + path: '/', + cwd: root, + io: { + stdout: (text) => { + stdout += text + }, + stderr: (text) => { + stderr += text + }, + }, + }), + /app\/entry\.ts:1:52/, + ) + assert.ok(Date.now() - started < 10_000) + assert.equal(mockClosed, 1) + assert.equal(stdout, '') + assert.doesNotMatch(stderr, /entry\.ts:1:52/) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + test('execute --target mock runs local code against the mock', async () => { const cache = await mkdtemp(join(tmpdir(), 'kody-exec-mock-')) const previous = process.env.KODY_CACHE_DIR diff --git a/test/local-bundle.test.ts b/test/local-bundle.test.ts index 63c399a..f690458 100644 --- a/test/local-bundle.test.ts +++ b/test/local-bundle.test.ts @@ -65,6 +65,25 @@ export default async () => path.join('a', await readFile('x', 'utf8')) assert.match(source, /from\s+["']node:path["']/) }) +test('bundleLocalUserModule keeps bare Node builtins external, like hosted publish', async () => { + const dir = writeFixture({ + 'entry.mjs': `import { Readable } from 'stream' +import { readFile } from 'fs/promises' +import { cjs } from 'cjs-dep' +export default async () => [Readable.name, await readFile('x', 'utf8'), cjs] +`, + 'node_modules/cjs-dep/package.json': `{"name":"cjs-dep","main":"index.js"} +`, + 'node_modules/cjs-dep/index.js': `const util = require('util') +exports.cjs = typeof util.inspect +`, + }) + const source = await bundleLocalUserModule(join(dir, 'entry.mjs')) + assert.match(source, /from\s+["']stream["']/) + assert.match(source, /from\s+["']fs\/promises["']/) + assert.match(source, /require\(["']util["']\)/) +}) + test('bundleLocalUserModule bundles bare npm imports from node_modules', async () => { const dir = writeFixture({ 'entry.mjs': `import { label } from 'tiny-dep' diff --git a/test/package-local-runtime-source.test.ts b/test/package-local-runtime-source.test.ts new file mode 100644 index 0000000..1935944 --- /dev/null +++ b/test/package-local-runtime-source.test.ts @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import * as esbuild from 'esbuild' +import { createPackageAppEntrySource } from '../src/package-local-runtime-source.js' + +test('the kody dev app entry parses and explains a request without the run secret', async () => { + const source = createPackageAppEntrySource({ sideEffectModules: [] }) + await esbuild.transform(source, { loader: 'js', format: 'esm' }) + assert.match(source, /Forbidden: missing dev run secret\. Open the URL kody dev printed/) +})