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
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Or run via `npx @kodycodes/cli` without a global install.
| `kody whoami` | Confirms the CLI MCP connection and lists tools. With a scoped API token (and no login), shows token identity via the Open API. |
| `kody search [query]` | Calls Kody `search` from the CLI (prefer the host MCP tool). Token-only auth uses Open API `GET /v1/search`. |
| `kody api <operationId>` | Thin Open API wrapper matching the MCP `api` tool: `operationId` + flat `--params` JSON. Auth: `--token` / `KODY_API_TOKEN` / stored `auth bootstrap` token. |
| `kody execute` | Calls Kody `execute` from the CLI (`--invoke`, `--code`, `--file`, or stdin via `--file -`). With a scoped API token and no login (or with `--token`), cloud execute goes through CapabilityProxy → `kody.execute` — no `kody login`. Add `--local` to run the module (and static `kody:@…` package modules) on this machine instead. |
| `kody execute` | Calls Kody `execute` from the CLI (`--invoke`, `--code`, `--file`, or stdin via `--file -`). With a scoped API token and no login (or with `--token`), cloud execute goes through CapabilityProxy → `kody.execute` — no `kody login`. Add `--local` to run the module (and static `kody:@…` package modules) on this machine instead. `--allow-private-network` opts local execution into private and local network access. |

`--json` prints structured MCP results.

Expand Down Expand Up @@ -102,8 +102,12 @@ npx @kodycodes/cli api usageGet --params '{}'
`tokenCreate` for CI/headless (`--token` / `KODY_API_TOKEN`).
- For `execute --local` specifically: `--token` / `KODY_API_TOKEN` wins when
set; else a stored bootstrap/API token from `auth bootstrap`; else the
stored `kody login` OAuth access token as Bearer (no under-the-hood
`tokenCreate`). The Open API must accept that OAuth bearer on
stored `kody login` OAuth access token as Bearer only when the API and MCP
URLs are paired (no under-the-hood `tokenCreate`). The default pair is
`https://api.kody.codes` and `https://kody.codes/mcp`; preview workers pair
when the API worker name adds `-api`, and loopback hosts pair at any port.
For a different API origin, use `auth bootstrap --api-url <origin>` or set
`KODY_API_TOKEN`. The Open API must accept that OAuth bearer on
CapabilityProxy / package-graph
([kentcdodds/kody#2812](https://github.com/kentcdodds/kody/issues/2812));
until then use bootstrap or a scoped `kody_at_…` token.
Expand Down Expand Up @@ -183,8 +187,10 @@ npx @kodycodes/cli execute --local --file ./task.js --params '{"to":"me@example.
`--local` is the thin passthrough for a single export (still cloud).
`packageStorage()`, `packageSecrets`, `email`, and `events` stay unbound on
the ad hoc entry like cloud execute unless the downloaded package modules
carry stamps. Outbound `fetch` goes straight from this machine, including
to local-network hosts.
carry stamps. Outbound `fetch` is public-network-only by default. Pass
`--allow-private-network` with `execute --local` to also allow private and
local addresses; the loopback bridge used for CapabilityProxy calls remains
available either way.

## Token storage

Expand Down
16 changes: 13 additions & 3 deletions skills/kody/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,13 @@ fails clearly rather than silently billing a remote execute.
For `execute --local` auth: `--token` / `KODY_API_TOKEN` wins when set;
otherwise a stored token from `kody auth bootstrap --code` (after MCP
`cliCredentialBootstrap`); otherwise a valid `kody login` session (OAuth
access token as Bearer — never print it, never mint via `tokenCreate` under
the hood). Prefer bootstrap over `tokenCreate` for agents already on MCP so
`kody_at_…` never appears in chat. Until the Open API accepts MCP OAuth on
access token as Bearer only when the API and MCP URLs are paired — never print
it, never mint via `tokenCreate` under the hood). The default pair is
`https://api.kody.codes` and `https://kody.codes/mcp`; preview workers pair
when the API worker name adds `-api`, and loopback hosts pair at any port.
For another API origin, use `auth bootstrap --api-url <origin>` or set
`KODY_API_TOKEN`. Prefer bootstrap over `tokenCreate` for agents already on
MCP so `kody_at_…` never appears in chat. Until the Open API accepts MCP OAuth on
CapabilityProxy / package-graph
(https://github.com/kentcdodds/kody/issues/2812), use bootstrap or mint a
scoped token with the MCP `api` tool `tokenCreate` — include `local-execute`
Expand All @@ -100,6 +104,12 @@ token is missing a scope; `feature_disabled` means the account
# cliCredentialBootstrap → { bootstrap_code, cli_command }
npx @kodycodes/cli auth bootstrap --code 'kody_bc_…'
npx @kodycodes/cli execute --local --file ./task.js --params '{"q":"email"}'
# Opt in to private and local network access if needed:
npx @kodycodes/cli execute --local --allow-private-network --file ./task.js

Local execute is public-network-only by default. `--allow-private-network`
opts into private and local addresses; the loopback CapabilityProxy bridge
continues to work either way.

npx @kodycodes/cli login
npx @kodycodes/cli execute --local --file ./task.js --params '{"q":"email"}'
Expand Down
66 changes: 65 additions & 1 deletion src/api-token.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { loadStoredApiToken } from './api-token-store.js'
import { apiTokenEnvVar, defaultApiUrl } from './defaults.js'
import {
apiTokenEnvVar,
defaultApiUrl,
defaultMcpUrl,
} from './defaults.js'
import type { SecretBackend, StoreResolution } from './store.js'

/** Platform tracking for login OAuth as CapabilityProxy / package-graph Bearer. */
Expand All @@ -16,6 +20,66 @@ export type ResolveScopedApiTokenInput = {
loadApiToken?: typeof loadStoredApiToken
}

const loopbackHosts = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])

function tryParseUrl(value: string): URL | null {
try {
return new URL(value)
} catch {
return null
}
}

function isWorkersDevHostname(hostname: string): boolean {
return hostname.endsWith('.workers.dev')
}

/** Whether a login OAuth bearer may be sent to this API origin. */
export function isPairedApiUrl(
apiUrl: string = defaultApiUrl,
mcpUrl: string = defaultMcpUrl,
): boolean {
const api = tryParseUrl(apiUrl)
const mcp = tryParseUrl(mcpUrl)
if (!api || !mcp) return false

const apiIsLoopback = loopbackHosts.has(api.hostname.toLowerCase())
const mcpIsLoopback = loopbackHosts.has(mcp.hostname.toLowerCase())
if (apiIsLoopback && mcpIsLoopback) {
return ['http:', 'https:'].includes(api.protocol) &&
['http:', 'https:'].includes(mcp.protocol)
}
if (api.protocol !== 'https:' || mcp.protocol !== 'https:') return false
const apiIsWorkersDev = isWorkersDevHostname(api.hostname)
const mcpIsWorkersDev = isWorkersDevHostname(mcp.hostname)
if (apiIsWorkersDev || mcpIsWorkersDev) {
if (!apiIsWorkersDev || !mcpIsWorkersDev) return false
const apiLabels = api.hostname.toLowerCase().split('.')
const mcpLabels = mcp.hostname.toLowerCase().split('.')
return (
apiLabels.length === mcpLabels.length &&
apiLabels.slice(1).join('.') === mcpLabels.slice(1).join('.') &&
apiLabels[0] === `${mcpLabels[0]}-api`
)
}
return api.hostname.toLowerCase() === `api.${mcp.hostname.toLowerCase()}`
}

export function expectedPairedApiOrigin(mcpUrl: string): string {
const mcp = tryParseUrl(mcpUrl)
if (!mcp) return 'a paired API origin'
if (loopbackHosts.has(mcp.hostname.toLowerCase())) {
return 'a loopback API origin'
}
const hostname = mcp.hostname.toLowerCase()
if (isWorkersDevHostname(hostname)) {
const labels = hostname.split('.')
labels[0] = `${labels[0]}-api`
return `https://${labels.join('.')}`
}
return `https://api.${hostname}`
}

/**
* Scoped Open API / CapabilityProxy token (`kody_at_…`). Same source for
* `execute --local`, token-only cloud execute, and Open API search/whoami.
Expand Down
9 changes: 9 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ function parseKnown(args: Array<string>) {
params: { type: 'string' },
'conversation-id': { type: 'string' },
local: { type: 'boolean' },
'allow-private-network': { type: 'boolean' },
token: { type: 'string' },
'api-url': { type: 'string' },
project: { type: 'boolean' },
Expand Down Expand Up @@ -152,6 +153,12 @@ async function dispatch(
mcpUrl: typeof parsed.values['mcp-url'] === 'string' ? parsed.values['mcp-url'] : undefined,
})
const json = parsed.values.json === true
if (
parsed.values['allow-private-network'] === true &&
(parsed.command !== 'execute' || parsed.values.local !== true)
) {
throw new Error('--allow-private-network can only be used with execute --local.')
}

switch (parsed.command) {
case 'help':
Expand Down Expand Up @@ -448,6 +455,8 @@ async function dispatch(
purpose: 'execute --local',
}),
apiUrl,
allowPrivateNetwork:
parsed.values['allow-private-network'] === true,
onStatus: (message) => writeErr(`${message}\n`),
})
: useToken
Expand Down
8 changes: 6 additions & 2 deletions src/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Usage:
kody search [query] [--entity <ref>] [--domain <id>] [--limit <n>] [--token <token>] [--api-url <url>] [--json]
kody api <operationId> [--params <json>] [--token <token>] [--api-url <url>] [--json]
kody execute [--invoke <ref> | --code <esm> | --file <path>] [--params <json>] [--conversation-id <id>] [--json]
[--token <token>] [--api-url <url>] [--local]
[--token <token>] [--api-url <url>] [--local] [--allow-private-network]
kody install [--mcp-url <url>] [--clients <ids>] [--yes] [--project] [--json]
kody skill install [--project]

Expand Down Expand Up @@ -61,13 +61,17 @@ Usage:
--local Run the execute module on this machine (workerd, Linux/macOS).
Requires Node.js 22 or newer. Auth: \`--token\` /
${apiTokenEnvVar}, else stored \`auth bootstrap\` token, else
\`kody login\`. Static kody:@… imports are fetched via POST
paired \`kody login\` credentials. Static kody:@… imports are fetched via POST
/v1/local-execute/package-graph and embedded in local workerd
(CapabilityProxy only for per-call kody:runtime hops — never a
whole-module cloud kody.execute defer). Fails clearly when that
package-graph API is unavailable. Cloud token execute (no
--local) uses CapabilityProxy → kody.execute for every module.

--allow-private-network
Allow execute --local code to fetch private and local network
addresses. Default is public-only; this flag requires --local.

Environment:
KODY_MCP_URL Override the default MCP URL (${defaultMcpUrl})
${apiTokenEnvVar} Scoped API token for token-auth search / whoami / api / execute
Expand Down
21 changes: 21 additions & 0 deletions src/local-execute-auth.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import { ensureFreshCredentials } from './auth.js'
import {
missingLocalExecuteAuthMessage,
expectedPairedApiOrigin,
isPairedApiUrl,
resolveScopedApiToken,
type ResolveScopedApiTokenInput,
} from './api-token.js'
import { defaultApiUrl, defaultMcpUrl } from './defaults.js'
import type { SecretBackend } from './store.js'

function urlOriginOrValue(value: string): string {
try {
return new URL(value).origin
} catch {
return value
}
}

/**
* Bearer for CapabilityProxy / package-graph under `execute --local`.
*
Expand All @@ -31,6 +42,16 @@ export async function resolveLocalExecuteBearer(
const token = resolveScopedApiToken(input)
if (token) return token

const apiUrl = input.apiUrl ?? defaultApiUrl
const mcpUrl = input.mcpUrl ?? defaultMcpUrl
if (!isPairedApiUrl(apiUrl, mcpUrl)) {
const apiOrigin = urlOriginOrValue(apiUrl)
const mcpOrigin = urlOriginOrValue(mcpUrl)
throw new Error(
`kody login credentials for ${mcpOrigin} are only sent to ${expectedPairedApiOrigin(mcpUrl)}. For ${apiOrigin}, run \`kody auth bootstrap --code … --api-url ${apiOrigin}\` or set KODY_API_TOKEN.`,
)
}

const ensure = input.ensureCredentials ?? ensureFreshCredentials
try {
const credentials = await ensure({
Expand Down
8 changes: 7 additions & 1 deletion src/local-execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export type LocalExecuteInput = {
fetchFn?: typeof fetch
/** Skip the pinned download and run this workerd binary. */
workerdPath?: string
allowPrivateNetwork?: boolean
onStatus?: (message: string) => void
}

Expand Down Expand Up @@ -125,7 +126,12 @@ export async function runLocalExecute(input: LocalExecuteInput): Promise<ToolCal
const configPath = join(workDir, 'config.capnp')
await writeFile(
configPath,
createWorkerdConfig({ bridgePort: bridge.port, files, packageModules }),
createWorkerdConfig({
bridgePort: bridge.port,
files,
packageModules,
allowPrivateNetwork: input.allowPrivateNetwork,
}),
)

const env: NodeJS.ProcessEnv = { ...process.env, [runSecretEnvVar]: runSecret }
Expand Down
11 changes: 7 additions & 4 deletions src/local-runtime-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,9 +319,8 @@ export type WorkerdPackageModuleFile = {

/**
* workerd text config. `files` are embedded relative to the config file.
* Outbound fetch reaches public and private networks: local execute runs as
* the user on their own machine, and reaching local services is part of why
* one would run locally.
* Outbound fetch is public-only unless private-network access is explicitly
* enabled. The loopback bridge remains a separate external service.
*
* Optional `packageModules` are stamped `kody:@…` (and nested) modules from
* POST /v1/local-execute/package-graph — embedded alongside the user module so
Expand All @@ -331,8 +330,12 @@ export function createWorkerdConfig(input: {
bridgePort: number
files: { entry: string; user: string; runtime: string }
packageModules?: Array<WorkerdPackageModuleFile>
allowPrivateNetwork?: boolean
}): string {
const flags = localExecuteCompatibilityFlags.map((flag) => JSON.stringify(flag)).join(', ')
const networkAllow = input.allowPrivateNetwork
? '["public", "private", "local"]'
: '["public"]'
const packageEntries = (input.packageModules ?? [])
.map(
(module) =>
Expand All @@ -346,7 +349,7 @@ const config :Workerd.Config = (
services = [
(name = "main", worker = .kodyWorker),
(name = "kody-bridge", external = (address = "127.0.0.1:${input.bridgePort}", http = ())),
(name = "internet", network = (allow = ["public", "private", "local"], tlsOptions = (trustBrowserCas = true))),
(name = "internet", network = (allow = ${networkAllow}, tlsOptions = (trustBrowserCas = true))),
],
sockets = [ (name = "http", address = "127.0.0.1:0", http = (), service = "main") ],
);
Expand Down
Loading
Loading