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
26 changes: 14 additions & 12 deletions docs/contributing/packages-and-manifests.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,17 +124,17 @@ The top-level saved identity is the package.
- The author-facing storage prescription is one rule per context: saved-package
code always uses `packageStorage()` for the package's own data; ad hoc execute
code binds a `storageId` and uses ambient `storage`; another package's data
goes through `packages.invokeChecked`. Ambient `storage` inside package code
is a legacy pattern being removed in stages — it binds per-run (package bucket
for exports/invocations, job-/service-scoped buckets for jobs/services,
caller-bound or `undefined` under static import). Repo checks fail (the `lint`
result) when package sources import ambient `storage` from `kody:runtime` with
a value named import; type-only imports and `.d.ts` files are exempt. The rule
runs only where checks run — new session check runs, publishes, and community
fork installs — so already-published artifacts are never re-validated
retroactively and keep executing. Removing the ambient binding from
package-invocation contexts is a follow-up gated on an operator audit of
published artifacts confirming no remaining invocation-context usage.
goes through `packages.invokeChecked`. Package-invocation runs (exports,
subscription handlers, retrievers) bind no ambient `storage`: the legacy
package-bucket binding was removed after an operator audit of all published
artifacts confirmed no remaining usage, so guard-less ambient access in those
contexts fails with the structured `runtime_helper_unbound` hint pointing at
`packageStorage()`. Job and service runtimes still bind job-/service-scoped
scratch buckets. Repo checks fail (the `lint` result) when package sources
import ambient `storage` from `kody:runtime` with a value named import;
type-only imports and `.d.ts` files are exempt. The rule runs only where
checks run — new session check runs, publishes, and community fork installs —
so already-published artifacts are never re-validated retroactively.
- Callable exports are resolved from package exports, not from a second Kody
registry.
- Packages may also export non-callable helper modules and values for reuse.
Expand Down Expand Up @@ -405,7 +405,9 @@ automatic context retrieval without promoting those records to durable memory.
more scopes: `search`, `context`
- Package metadata is the source of truth; runtime discovery uses derived KV
manifest and scope indexes that are rebuilt on package refresh
- Retriever exports run read-only against the package storage bucket
- Retriever exports reach the package storage bucket through `packageStorage()`
(writable, like every packageStorage surface); keeping retrievers read-mostly
is a convention — they no longer get a read-only ambient `storage` binding

Example:

Expand Down
2 changes: 1 addition & 1 deletion docs/guides/package-service-pattern.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Persist service state through `packageStorage()` (key run-scoped entries by
service name if needed). The legacy ambient `storage` binding in services is
service-scoped, invisible to other package surfaces, and importing it now fails
repo checks — see
[Ambient `storage` in package code](../use/packages.md#ambient-storage-in-package-code-legacy-being-removed).
[Ambient `storage` in package code](../use/packages.md#ambient-storage-in-package-code-removed).

The `service` helper exposes:

Expand Down
10 changes: 5 additions & 5 deletions docs/use/email-primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,11 @@ Inbound storage is quota-gated per user:
`email_attachment_get` (or `import { email } from 'kody:runtime'`) when they
need bodies or attachment bytes.
- Subscription handlers run with the normal package runtime context: signed-in
package user, package-owned storage `package:<packageId>`, package/repo
context, and the standard capability registry subject to the usual secret and
capability approval rules. For `email.message.received`, `import { email }`
from `kody:runtime` is available as a convenience helper for message lookup,
attachment lookup, and replies.
package user, package-owned storage `package:<packageId>` via
`packageStorage()`, package/repo context, and the standard capability registry
subject to the usual secret and capability approval rules. For
`email.message.received`, `import { email }` from `kody:runtime` is available
as a convenience helper for message lookup, attachment lookup, and replies.
- Attachments are metadata-first by default; raw MIME for small messages is
stored so on-demand attachment lookup can reconstruct bytes locally.

Expand Down
4 changes: 3 additions & 1 deletion docs/use/execute.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,9 @@ Kody supports durable storage binding for execute and scheduled jobs, including
package-owned jobs and non-package jobs created with `job_schedule` or
`job_schedule_once`.

- bound storage is execute-, app-, package-, or job-owned durable state
- bound storage is execute-, app-, or job-owned durable state; saved-package
invocation runs (exports, subscriptions, retrievers) bind no ambient `storage`
— package code reaches its own bucket through `packageStorage()`
- package service runs also get writable service-owned durable state scoped to
the declared service name
- package service runs are background-managed by the service Durable Object, so
Expand Down
49 changes: 26 additions & 23 deletions docs/use/packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,8 +251,8 @@ package's immutable id. One rule per context:
(`get`/`set`/`list`/`sql`/`delete`/`clear`/`id`), writable, always bound to the
declaring package's own bucket no matter where the code runs:

- In the package's own export/invocation runtime it is the same bucket ambient
`storage` binds.
- In the package's own export/invocation runtime it is the only way to reach the
package bucket — those runs bind no ambient `storage`.
- In package jobs and services — where ambient `storage` binds job-scoped and
service-scoped buckets — it reaches the package's shared bucket.
- When the module is statically imported (`kody:@scope/package/export`) into an
Expand Down Expand Up @@ -285,24 +285,26 @@ consequences:
package that is not the running package and not statically imported by the
bundle, use `packages.invokeChecked` so its own runtime does the reading.

### Ambient `storage` in package code (legacy, being removed)

Ambient `storage` from `kody:runtime` inside package code is a legacy pattern on
a staged removal path. It binds per-run: exports and invocations bind the
package's own bucket, jobs and services bind job-/service-scoped buckets, and
statically imported code gets the caller's binding or `undefined` — so code
written against ambient `storage` breaks as soon as it is statically imported
into another context.

Repo checks now fail when package source imports ambient `storage` from
`kody:runtime` (type-only imports and `.d.ts` files are exempt). This applies to
new check runs and publishes only: packages published earlier keep running
unchanged until the ambient binding is removed from package runtimes in a later
stage. Use `packageStorage()` instead — in the package's own runtime it is the
identical bucket, so switching is a rename. For run-scoped state that previously
lived in a job's or service's ambient bucket, either keep it in the package
bucket under a run-scoped key (for example keyed by `jobName`), or have ad hoc
callers bind an explicit `storageId`.
### Ambient `storage` in package code (removed)

Package exports, subscription handlers, and retrievers no longer bind ambient
`storage` — the legacy pattern where invocation runs bound it to the package's
own bucket has been removed. In those contexts ambient `storage` is simply
`undefined`; guard-less access to it fails with a structured
`runtime_helper_unbound` error whose remedy points at `packageStorage()`, the
one way package code reaches its bucket (same interface, same bucket, so the
migration is a rename).

Repo checks fail when package source imports ambient `storage` from
`kody:runtime` (type-only imports and `.d.ts` files are exempt), so the pattern
cannot be reintroduced at publish time.

Ambient `storage` still exists where it is not the legacy pattern: ad hoc
`execute` code with a `storageId` bound on the call (the prescribed use), and
package job and service runtimes, which still bind job-/service-scoped scratch
buckets distinct from the package bucket for already-published code. New package
source cannot import ambient `storage` (checks fail), so new job and service
code keeps run-scoped state in the package bucket under run-scoped keys instead.

## Package apps

Expand Down Expand Up @@ -388,9 +390,10 @@ Each subscription has:
- `filters` — optional topic-specific metadata reserved for event dispatchers

Subscription handlers run as package runtime modules with the signed-in package
user, package-owned storage, package context, secrets, and `kody:runtime`
helpers. Published bundle artifacts are rebuilt for subscription handlers during
package checks and publish, just like exports, services, jobs, and apps.
user, package-owned storage via `packageStorage()`, package context, secrets,
and `kody:runtime` helpers. Published bundle artifacts are rebuilt for
subscription handlers during package checks and publish, just like exports,
services, jobs, and apps.

Use the built-in `package_subscriptions_list` capability to discover the
signed-in user's saved package subscriptions, optionally filtered by exact
Expand Down
2 changes: 1 addition & 1 deletion packages/worker/src/mcp/tools/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Sandbox surface:
- \`import { kody } from 'kody:runtime'\` for builtin capabilities discovered by \`search\`; call valid identifier names as \`await kody.capability_id(input)\`. If a capability id is not a valid JavaScript identifier, use bracket notation: \`await kody["capability-id"](input)\`. Capability detail from \`search({ entity: "{name}:capability" })\` includes the exact snippet.
- Remote connector, user-added MCP server, and curated OpenAPI capabilities use namespaced accessors: \`await kody.remote["name"].capability_name(input)\`, \`await kody.mcp["server-name"].tool_name(input)\`, and \`await kody.openapi["name"].operation_slug(input)\` — never a flat \`kody.kind_instance_capability(...)\` call.
- Storage, one rule per context: ad hoc execute code uses \`import { storage } from 'kody:runtime'\` against the \`storageId\` bound to the call; saved-package code always uses \`packageStorage()\` from 'kody:runtime' for the package's own data (package repo checks fail on ambient \`storage\` imports); another package's data goes through \`packages.invokeChecked\`.
- \`storage\` exposes \`get\`/\`set\`/\`list\`/\`delete\`/\`clear\` and \`storage.sql(query, params?)\`, which returns \`{ columns, rows, rowCount, rowsRead, rowsWritten }\`; read query rows from \`.rows\`. It is \`undefined\` when the call binds no \`storageId\`.
- \`storage\` exposes \`get\`/\`set\`/\`list\`/\`delete\`/\`clear\` and \`storage.sql(query, params?)\`, which returns \`{ columns, rows, rowCount, rowsRead, rowsWritten }\`; read query rows from \`.rows\`. It is \`undefined\` when the call binds no \`storageId\`, and always \`undefined\` in saved-package invocation runs, which bind no ambient storage.
- \`packageStorage()\` returns the same storage interface bound to the declaring package's own bucket, in the package's own runtime and when the module is statically imported (\`kody:@scope/package/export\`) into an execute call or another package — no \`storageId\` needed. Inline execute code has no package provenance, so \`packageStorage()\` throws there.
- \`import { refreshAccessToken, createAuthenticatedFetch, oauthClientCredentials, secretHeaders } from 'kody:runtime'\` for OAuth integrations and secret-derived auth headers. Integration \`name\` may be account-specific (e.g. \`google-personal\`, \`google-business\`); call \`integration_list\` first when a provider may have multiple accounts connected. For client-credentials Basic Auth, save the id and secret separately and use \`secretHeaders.basic({ usernameSecret, passwordSecret, scope })\` in the Authorization header, or \`oauthClientCredentials(...)\` for the token request; do not ask users to precompute a derived Basic header.
- \`import { workflows } from 'kody:runtime'\` for durable Cloudflare Workflows. \`workflows.create\` accepts either inline \`code\` or a saved-package \`exportName\`; use \`workflow_run_list\` to inspect recent runs.
Expand Down
10 changes: 5 additions & 5 deletions packages/worker/src/package-invocations/service.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1937,11 +1937,6 @@ test('invokePackageSubscription uses the normal capability registry with package
message: { id: 'message-123' },
},
expect.objectContaining({
storageTools: {
userId: 'user-123',
storageId: 'package:pkg-1',
writable: true,
},
packageContext: {
packageId: 'pkg-1',
kodyId: 'discord-gateway',
Expand All @@ -1955,4 +1950,9 @@ test('invokePackageSubscription uses the normal capability registry with package
expect(
(runOptions as { skipCapabilityRegistry?: boolean }).skipCapabilityRegistry,
).toBeUndefined()
// Package invocation runs no longer bind ambient `storage`: the package
// bucket is reached via packageStorage(), granted through packageContext.
expect(
(runOptions as { storageTools?: unknown }).storageTools,
).toBeUndefined()
})
14 changes: 7 additions & 7 deletions packages/worker/src/package-invocations/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,10 @@ function parsePackageInvokeInput(
}

function buildPackageInvocationStorageId(packageId: string) {
// Shared with packageStorage() so a package's own runtime and foreign
// bundles that statically import it reach the same bucket.
// Shared with packageStorage() so all surfaces name the same bucket. Since
// the ambient `storage` binding was removed from invocation runs, this only
// feeds `callerContext.storageContext` (secret scoping and runtime-debug
// metadata) — package code reaches the bucket via `packageStorage()`.
return buildPackageStorageId(packageId)
}

Expand Down Expand Up @@ -1105,11 +1107,9 @@ async function invokeSavedPackageModule(input: {
},
input.params,
{
storageTools: {
userId: input.actor.userId,
storageId: buildPackageInvocationStorageId(input.savedPackage.id),
writable: true,
},
// No ambient `storage` binding: package code reaches its bucket via
// `packageStorage()` (granted through packageContext below). Legacy
// ambient use gets the structured runtime_helper_unbound hint.
runtimeDebug,
emailTools: {
getMessage: async (messageId) => {
Expand Down
15 changes: 10 additions & 5 deletions packages/worker/src/package-retrievers/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ function createRepoContext(source: EntitySourceRow) {
}

function buildPackageRetrieverStorageId(packageId: string) {
// Names the package bucket (same shape as buildPackageStorageId). Since the
// ambient `storage` binding was removed from retriever runs, this only
// feeds `callerContext.storageContext` and runtime-debug metadata —
// retriever code reaches the bucket via `packageStorage()`.
return `package:${encodeURIComponent(packageId)}`
}

Expand Down Expand Up @@ -194,11 +198,12 @@ async function invokeRetriever(input: {
conversationId: input.conversationId ?? null,
},
{
storageTools: {
userId: input.userId,
storageId: buildPackageRetrieverStorageId(input.entry.packageId),
writable: false,
},
// No ambient `storage` binding: retriever code reaches the package
// bucket via `packageStorage()` (granted through packageContext
// below). The old read-only ambient binding constrained only the
// ambient helper — `packageStorage()` has been writable in retriever
// context since it shipped — so retrievers staying read-mostly is a
// convention, not a runtime constraint.
packageContext,
runtimeDebug,
packageInvokeTools: createPackageRuntimeInvokeTools(
Expand Down
78 changes: 68 additions & 10 deletions packages/worker/src/package-runtime/package-storage.workers.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { env } from 'cloudflare:workers'
import { expect, test } from 'vitest'
import { createMcpCallerContext } from '#mcp/context.ts'
import { getExecutionErrorDetails } from '#mcp/executor.ts'
import { runBundledModuleWithRegistry } from '#mcp/run-kody-registry.ts'
import { ensureEntitlementTestSchema } from '#worker/entitlements/test-schema.ts'
import {
Expand Down Expand Up @@ -382,7 +383,7 @@ test(
)

test(
'a package statically imported into another package keeps its own bucket while the host package keeps ambient storage',
'a package statically imported into another package keeps its own bucket in the host package runtime',
{ timeout: 90_000 },
async () => {
silenceIncidentalRuntimeWarnings()
Expand Down Expand Up @@ -431,7 +432,7 @@ test(
'\treturn {',
'\t\tinner: await innerWhoami(),',
'\t\touterBucketId: packageStorage().id,',
'\t\tambientStorageId: storage.id,',
'\t\tambientStorageType: typeof storage,',
'\t}',
'}',
].join('\n'),
Expand All @@ -446,6 +447,8 @@ test(
entryPoint: 'src/run.ts',
rootPackageId: outerPackageId,
})
// Package invocation runs bind no ambient storage (no storageTools):
// the package bucket is reachable only through packageStorage().
const result = await runBundledModuleWithRegistry(
env,
createCallerContext(userId),
Expand All @@ -458,11 +461,6 @@ test(
kodyId: 'outer',
sourceId: `source-${outerUnique}`,
},
storageTools: {
userId,
storageId: buildPackageStorageId(outerPackageId),
writable: true,
},
},
)
expect(result.error).toBeUndefined()
Expand All @@ -471,14 +469,74 @@ test(
bucketId: buildPackageStorageId(inner.packageId),
owner: 'inner-bucket',
},
// In the package's own runtime, packageStorage() and ambient
// storage reach the same bucket.
outerBucketId: buildPackageStorageId(outerPackageId),
ambientStorageId: buildPackageStorageId(outerPackageId),
// Ambient storage is absent in package-invocation runs; guarded
// access observes undefined.
ambientStorageType: 'undefined',
})
},
)

test(
'a package invocation run that dereferences ambient storage gets the packageStorage() unbound hint',
{ timeout: 90_000 },
async () => {
silenceIncidentalRuntimeWarnings()
await ensureSavedPackageArtifactSchema()
const userId = `user-${crypto.randomUUID()}`
const unique = crypto.randomUUID()
const packageId = `pkg-${unique}`
// Legacy package code (published before the #820 check) that uses the
// ambient storage helper guard-lessly: with the invocation-context
// binding removed, the bare TypeError is rewritten to the structured
// runtime_helper_unbound hint whose remedy leads with packageStorage().
const bundle = await buildKodyModuleBundle({
env,
baseUrl: 'https://kody.dev',
userId,
sourceFiles: {
'package.json': JSON.stringify({
name: '@kentcdodds/legacy',
exports: { './main': './src/main.ts' },
kody: { id: 'legacy', description: 'Legacy ambient storage' },
}),
'src/main.ts': [
"import { storage } from 'kody:runtime'",
'export default async function main() {',
"\treturn await storage.get('key')",
'}',
].join('\n'),
},
entryPoint: 'src/main.ts',
rootPackageId: packageId,
})
const result = await runBundledModuleWithRegistry(
env,
createCallerContext(userId),
bundle,
undefined,
{
skipCapabilityRegistry: true,
packageContext: {
packageId,
kodyId: 'legacy',
sourceId: `source-${unique}`,
},
},
)
expect(result.error).toBeDefined()
const details = getExecutionErrorDetails(result.error)
expect(details).toMatchObject({
kind: 'runtime_helper_unbound',
helperName: 'storage',
})
expect(details?.nextStep).toContain('packageStorage()')
expect(details?.nextStep?.indexOf('packageStorage()')).toBeLessThan(
details?.nextStep?.indexOf('storageId') ?? -1,
)
},
)

test(
'inline execute code without package provenance gets an actionable packageStorage error',
{ timeout: 60_000 },
Expand Down
Loading