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
13 changes: 10 additions & 3 deletions .agents/skills/ship-pr/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,16 @@ description: >

By default, continue to Discord message with summary and include PR link

If explicitly requested, merge PR as Kody, watch CI deploy, when finished,
continue to the discord message and include a summary with link to PR, deployed
URL link, or failing job link.
If explicitly requested, merge PR as Kody with
`kody:@kentcdodds/github-pr-tools/merge-pr` using
`{ prUrl, mergeMethod: 'squash' }` (or `{ owner, repo, prNumber, ... }`;
optional `commitTitle`), watch CI deploy, when finished, continue to the discord
message and include a summary with link to PR, deployed URL link, or failing job
link.

Other useful exports on the same package: `get-pr-checks` for check-run status
without `gh`, and `github-client` (`ghRestFetch` / `ghGraphQL`) for one-off
authenticated GitHub calls.

## Done → Discord

Expand Down
26 changes: 26 additions & 0 deletions docs/guides/secret-backed-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,32 @@ contract:
If the auth contract has multiple fields, save only the truly sensitive fields
as secrets. Keep readable identifiers and defaults in values.

## Using a saved secret in `fetch`

Saved secrets are referenced by **placeholder**, never by plaintext. Inside
`execute` (and package code), put `{{secret:name}}` — or
`{{secret:name|scope=user}}` to pin a scope — in the URL, headers, or body of an
outbound `fetch`. Kody resolves the placeholder for **approved** hosts only:

```ts
const response = await fetch('https://api.example.com/v1/me', {
headers: {
Authorization: 'Bearer {{secret:providerAccessToken}}',
},
})
```

Rules:

- `kody.secret_list({})` returns metadata only (names, allowed hosts) — use it
to find the right secret name, then reference that name in a placeholder.
- Placeholders only resolve in secret-aware `fetch` paths and capability inputs
marked `x-kody-secret`; they are not general string interpolation.
- Never echo the literal `{{secret:...}}` form into chat, logs, issue bodies, or
any content that may later be sent over `fetch`.
- For Basic Auth derived from two secrets, use `secretHeaders.basic(...)` from
`kody:runtime` (see the secrets usage docs).

## When `/account/secrets/new` is enough

In the common case, `/account/secrets/new` is the whole setup surface.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createMcpCallerContext } from '#mcp/context.ts'
import { isEntitlementLimitError } from '#worker/entitlements/errors.ts'
import { planLimits } from '#worker/entitlements/plans.ts'
import { createStableUserIdFromEmail } from '#worker/user-id.ts'
import { repoOpenSessionInputSchema } from './repo-shared.ts'

const mockModule = vi.hoisted(() => ({
getActiveRepoSessionByConversation: vi.fn(),
Expand Down Expand Up @@ -130,6 +131,26 @@ function resetMocks() {
mockModule.repoSessionRpc.mockReset()
}

test('repo target accepts camelCase aliases for its snake_case fields', () => {
// Agents guess `kodyId` / `packageId` often enough that the schema
// normalizes both spellings instead of failing the round trip.
expect(
repoOpenSessionInputSchema.parse({
target: { kind: 'package', kodyId: 'triage-github-pr' },
}).target,
).toEqual({ kind: 'package', kody_id: 'triage-github-pr' })
expect(
repoOpenSessionInputSchema.parse({
target: { kind: 'package', packageId: 'package-1' },
}).target,
).toEqual({ kind: 'package', package_id: 'package-1' })
expect(
repoOpenSessionInputSchema.parse({
target: { kind: 'package', kody_id: 'triage-github-pr' },
}).target,
).toEqual({ kind: 'package', kody_id: 'triage-github-pr' })
})

test('repo_open_session enforces the repo sessions entitlement for plan users opening a new session', async () => {
resetMocks()
const email = 'planned@example.com'
Expand Down
29 changes: 28 additions & 1 deletion packages/worker/src/mcp/capabilities/repo/repo-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { privateVisibilityChangeConfirmationDescription } from '#worker/repo/sou
export const repoSearchModeSchema = z.enum(['literal', 'regex'])
export const repoSearchOutputModeSchema = z.enum(['content', 'files'])

export const repoTargetSchema = z.union([
const repoTargetShapeSchema = z.union([
z.object({
kind: z.literal('package'),
package_id: z
Expand All @@ -28,6 +28,33 @@ export const repoTargetSchema = z.union([
}),
])

/**
* Agents routinely guess camelCase (`kodyId`, `packageId`) for the snake_case
* target fields and burn a round trip on the validation error, so the schema
* accepts both spellings and normalizes to snake_case.
*/
function normalizeRepoTargetAliases(value: unknown) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return value
}
const record = value as Record<string, unknown>
const normalized: Record<string, unknown> = { ...record }
if (normalized['package_id'] === undefined && 'packageId' in record) {
normalized['package_id'] = record['packageId']
delete normalized['packageId']
}
if (normalized['kody_id'] === undefined && 'kodyId' in record) {
normalized['kody_id'] = record['kodyId']
delete normalized['kodyId']
}
return normalized
}

export const repoTargetSchema = z.preprocess(
normalizeRepoTargetAliases,
repoTargetShapeSchema,
)

export const repoResolvedTargetSchema = z.union([
z.object({
kind: z.literal('source'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const secretListCapability = defineDomainCapability(
{
name: 'secret_list',
description:
'List available secret references for the signed-in user. Results include metadata such as names, descriptions, allowed hosts, and allowed kody. Use `kody.secret_list({ scope })` inside execute-time code when you want the same metadata from the sandbox. Use `/account/secrets/new` for user-provided API key, token, and credential entry or rotation.',
'List available secret references for the signed-in user. Results include metadata such as names, descriptions, allowed hosts, and allowed kody. Use `kody.secret_list({ scope })` inside execute-time code when you want the same metadata from the sandbox. To use a listed secret in an outbound `fetch`, reference it by placeholder — e.g. `Authorization: "Bearer {{secret:name}}"` (optionally `{{secret:name|scope=user}}`) — and Kody resolves it for approved hosts; plaintext values are never returned. Use `/account/secrets/new` for user-provided API key, token, and credential entry or rotation.',
keywords: ['secret', 'list', 'discovery', 'metadata', 'credentials'],
readOnly: true,
idempotent: true,
Expand Down
2 changes: 1 addition & 1 deletion packages/worker/src/mcp/capabilities/secrets/secret-set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const secretSetCapability = defineDomainCapability(
{
name: 'secret_set',
description:
'Create or update a stored secret reference for the signed-in user. Use this for server-side persistence of secret values that are already available inside trusted execution, such as refreshed OAuth tokens. Use `/account/secrets/new` for user-provided API key, token, and credential entry or rotation. Host use and direct capability access are authorized through secret policy approvals.',
'Create or update a stored secret reference for the signed-in user. Use this for server-side persistence of secret values that are already available inside trusted execution, such as refreshed OAuth tokens. Use `/account/secrets/new` for user-provided API key, token, and credential entry or rotation. Host use and direct capability access are authorized through secret policy approvals. Saved secrets are consumed in outbound `fetch` calls by placeholder, e.g. `{{secret:name}}`, resolved only for approved hosts.',
keywords: ['secret', 'persist', 'store', 'oauth', 'token', 'credential'],
readOnly: false,
idempotent: false,
Expand Down
24 changes: 24 additions & 0 deletions packages/worker/src/mcp/executor.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,9 +719,33 @@ test('executor maps secret errors, formats guidance, extracts raw content, and t
},
})

// A bare ReferenceError for a kody:runtime export must point at the
// missing import instead of leaving the caller to guess.
expect(
getExecutionErrorDetails(new Error('kody is not defined')),
).toMatchObject({
kind: 'runtime_import_missing',
exportName: 'kody',
nextStep: expect.stringContaining("import { kody } from 'kody:runtime'"),
suggestedAction: { type: 'fix_code' },
})
expect(
getExecutionErrorDetails(
new Error('ReferenceError: secretHeaders is not defined'),
),
).toMatchObject({
kind: 'runtime_import_missing',
exportName: 'secretHeaders',
})
// Unknown identifiers stay unhinted: they are ordinary user-code bugs.
expect(
getExecutionErrorDetails(new Error('myHelper is not defined')),
).toBeNull()

const errors = [
capabilityError,
new Error(createMissingSecretMessage('missingToken')),
new Error('kody is not defined'),
]
for (const error of errors) {
const output = formatExecutionOutput({ error } as const)
Expand Down
53 changes: 53 additions & 0 deletions packages/worker/src/mcp/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,15 @@ export type ExecutionErrorDetails =
type: 'sign_in'
}
}
| {
kind: 'runtime_import_missing'
message: string
nextStep: string
exportName: string
suggestedAction: {
type: 'fix_code'
}
}
| {
kind: 'entitlement_limit_exceeded'
message: string
Expand Down Expand Up @@ -892,9 +901,53 @@ export function getExecutionErrorDetails(
}
}

const missingRuntimeExport = parseMissingRuntimeExportMessage(message)
if (missingRuntimeExport) {
return {
kind: 'runtime_import_missing',
message,
nextStep: `\`${missingRuntimeExport}\` is provided by the Kody runtime module and must be imported: add \`import { ${missingRuntimeExport} } from 'kody:runtime'\` at the top of the module, then retry.`,
exportName: missingRuntimeExport,
suggestedAction: {
type: 'fix_code',
},
}
}

return null
}

/**
* Named exports of the virtual `kody:runtime` module (see
* `buildRuntimeModuleSource` in `#worker/package-runtime/module-graph.ts`).
* Referencing one without the import throws a bare `X is not defined`
* ReferenceError that gives no hint about the required import.
*/
const kodyRuntimeExportNames = new Set([
'kody',
'storage',
'refreshAccessToken',
'createAuthenticatedFetch',
'secretHeaders',
'oauthClientCredentials',
'packageContext',
'serviceContext',
'service',
'packageSecrets',
'email',
'workflows',
'packages',
'events',
])

function parseMissingRuntimeExportMessage(message: string) {
const match = /^(?:ReferenceError: )?(\w+) is not defined\b/.exec(message)
const exportName = match?.[1]
return exportName && kodyRuntimeExportNames.has(exportName)
? exportName
: null
}

function toEntitlementExecutionErrorDetails(
message: string,
details: EntitlementLimitErrorDetails,
Expand Down
Loading