Skip to content
This repository was archived by the owner on Oct 16, 2026. It is now read-only.
15 changes: 15 additions & 0 deletions src/api-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}).`
)
}
Comment thread
cursor[bot] marked this conversation as resolved.

/** Preferred interactive path for agents already on Kody MCP (ADR 0056). */
export function cliBootstrapInstructions(
lifetime: CliTokenLifetimeAlias = 'short',
Expand Down
5 changes: 2 additions & 3 deletions src/capability-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
)
}
Expand Down
55 changes: 31 additions & 24 deletions src/dev-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,30 +319,37 @@ export async function runDevFetch(input: {
json?: boolean
io: DevIo
}): Promise<number> {
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,
Expand Down
20 changes: 19 additions & 1 deletion src/dev/dev-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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]'
}
Expand Down Expand Up @@ -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}]`
Expand Down
1 change: 1 addition & 0 deletions src/dev/dev-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export type DevServerStatus = {
message: string
stack: string
request: { id: string; method: string; path: string } | null
fix?: string
at: string
}
| null
Expand Down
75 changes: 73 additions & 2 deletions src/local-bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
kody-bot marked this conversation as resolved.

/**
* 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 {
Expand All @@ -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
}

Expand Down
5 changes: 2 additions & 3 deletions src/local-package-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import {
} from './capability-proxy.js'
import { localWorkerModuleNames } from './local-runtime-source.js'
import {
apiTokenMintInstructions,
cliBootstrapInstructions,
capabilityProxyForbiddenMessage,
currentStoredTokenLifetimeAlias,
featureDisabledMessage,
insufficientScopeMessage,
Expand Down Expand Up @@ -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 },
)
}
Expand Down
9 changes: 7 additions & 2 deletions src/local-runtime-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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<void>((resolve, reject) => {
server.once('error', reject)
Expand Down
2 changes: 1 addition & 1 deletion src/local-runtime-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
19 changes: 16 additions & 3 deletions src/package-app-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 })
Expand Down
7 changes: 5 additions & 2 deletions src/package-local-runtime-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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) => {
Expand Down
22 changes: 22 additions & 0 deletions test/capability-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kody_bc_…> --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: {
Expand Down
Loading
Loading