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
15 changes: 8 additions & 7 deletions docs/contributing/packages-and-manifests.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,10 @@ The agent-facing package-reuse contract is two rules:
imports from execute always see the current published version; snapshot
staleness only affects package-to-package static dependencies.
2. **Name is data, the call needs the target package's own runtime, or the call
needs exactly-once → `packages.invoke`.** Package runtime contexts and
authenticated ad hoc execute calls expose it from `kody:runtime`. It is the
only dynamic primitive and is always contract-checked before invoking.
needs exactly-once → `packages.invoke`.** Package runtime contexts,
authenticated ad hoc execute calls, and standalone scheduled jobs expose it
from `kody:runtime`. It is the only dynamic primitive and is always
contract-checked before invoking.

```ts
import { packages } from 'kody:runtime'
Expand Down Expand Up @@ -238,10 +239,10 @@ Security and loop safeguards:

- Resolution is same-user only; package code cannot invoke another user's saved
package.
- `packages.invoke` requires either package runtime context or authenticated
execute context. Static `kody:@...` imports remain library imports in the
caller's runtime; use keyless `packages.invoke` when execute needs to enter a
package runtime.
- `packages.invoke` requires package runtime context, authenticated execute
context, or a standalone scheduled job. Static `kody:@...` imports remain
library imports in the caller's runtime; use keyless `packages.invoke` when
execute or a standalone job needs to enter a package runtime.
- Nested dynamic package invocations are depth-limited to prevent runaway
package-to-package loops.

Expand Down
8 changes: 4 additions & 4 deletions docs/guides/package-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ invoke it instead of creating another implementation.
per call, so static imports from execute always see the current published
version.
- Use keyless `packages.invoke({ kodyId, exportName, params })` from an
authenticated `execute` call or package runtime when the target's name is data
or the call must run in the target package's own runtime (package secret
mounts, `packageStorage()`, `packageContext`). Pass `idempotencyKey` only when
the call needs exactly-once semantics.
authenticated `execute` call, a standalone scheduled job, or package runtime
when the target's name is data or the call must run in the target package's
own runtime (package secret mounts, `packageStorage()`, `packageContext`).
Pass `idempotencyKey` only when the call needs exactly-once semantics.

This is the default for an established operation whose behavior should stay
owned by its existing capability or package.
Expand Down
28 changes: 14 additions & 14 deletions docs/use/execute.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,15 @@ helpers are runtime exports:
- use **`import { packageContext } from 'kody:runtime'`** inside saved package
code when you need package metadata; it is **`null`** for ad hoc execute calls
- use **`import { packages } from 'kody:runtime'`** inside saved package runtime
contexts or authenticated execute calls when a package call must be dynamic:
the target's name is data, the call needs the target package's own runtime, or
you need exactly-once. `packages.invoke({ kodyId, exportName, params })` (plus
an optional `idempotencyKey` field for exactly-once calls) is the only dynamic
call and is always contract-checked before invoking. Pass the bare
`package.json#kody.id` as `kodyId` (for example, `github`), not the npm-scoped
`package.json.name` (for example, `@kentcdodds/github`). When the target
package's name is known when the code is written, use a static `kody:@...`
import instead (see below)
contexts, authenticated execute calls, or standalone scheduled jobs when a
package call must be dynamic: the target's name is data, the call needs the
target package's own runtime, or you need exactly-once.
`packages.invoke({ kodyId, exportName, params })` (plus an optional
`idempotencyKey` field for exactly-once calls) is the only dynamic call and is
always contract-checked before invoking. Pass the bare `package.json#kody.id`
as `kodyId` (for example, `github`), not the npm-scoped `package.json.name`
(for example, `@kentcdodds/github`). When the target package's name is known
when the code is written, use a static `kody:@...` import instead (see below)
- use **`import thing from 'kody:@scope/my-package/export-name'`** or
**`import { helper } from 'kody:@scope/my-package/export-name'`** to reuse a
saved package export by npm-scoped package name. This is **the default for
Expand Down Expand Up @@ -209,11 +209,11 @@ but `packageContext` remains **`null`** because the imported module has not been
entered as its own package runtime. This is fine for most reuse — packages
backed by user-scope secrets (for example `github`) work fully through plain
static imports because `{{secret:...}}` placeholders resolve at the fetch
gateway under the calling user. When execute must enter a saved package export
as that package — package-mounted secrets (`kody.secretMounts`),
`packageContext`, the package's own `packageStorage()` bucket — use keyless
`packages.invoke` from `kody:runtime`. It resolves the bare `kodyId`, such as
`my-package`, rather than the npm-scoped package name, such as
gateway under the calling user. When execute or a standalone scheduled job must
enter a saved package export as that package — package-mounted secrets
(`kody.secretMounts`), `packageContext`, the package's own `packageStorage()`
bucket — use keyless `packages.invoke` from `kody:runtime`. It resolves the bare
`kodyId`, such as `my-package`, rather than the npm-scoped package name, such as
`@scope/my-package`.

When you need to edit saved source, prefer the repo-backed workflow in
Expand Down
17 changes: 9 additions & 8 deletions docs/use/packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,11 +241,11 @@ The primary identifier is the bare `kodyId`; `kody_id`, `packageId`, and
`package_id` are accepted aliases. `exportName` is required, and `export_name`
is accepted as an alias.

Package runtime contexts and authenticated ad hoc MCP `execute` calls can call
`packages.invoke`, and resolution is scoped to packages owned by the current
authenticated user. Package code does not need to mint or pass
package-invocation bearer tokens. Nested package invocations are depth-limited
to prevent runaway loops.
Package runtime contexts, authenticated ad hoc MCP `execute` calls, and
standalone scheduled jobs can call `packages.invoke`, and resolution is scoped
to packages owned by the current authenticated user. Package code does not need
to mint or pass package-invocation bearer tokens. Nested package invocations are
depth-limited to prevent runaway loops.

External trusted clients that must call package exports over HTTP use package
invocation tokens instead. Before sending a user to create one, agents should
Expand All @@ -262,9 +262,10 @@ platform (built-in) dependencies receive no `packageStorage()` grant and the
call fails closed inside live platform code. `{{secret:...}}` placeholders for
user-scope secrets still resolve at the fetch gateway under the calling user —
so secret-backed packages such as `github` work fully via plain static import.
Use keyless `packages.invoke` from execute when you need to enter a saved
package as that package so it receives `packageContext`, package-owned storage,
package-mounted secrets (`kody.secretMounts`), and its own `packages` helper.
Use keyless `packages.invoke` from execute or a standalone scheduled job when
you need to enter a saved package as that package so it receives
`packageContext`, package-owned storage, package-mounted secrets
(`kody.secretMounts`), and its own `packages` helper.

**Unsupported helpers:** `packages.invokeChecked`, `packages.check`, and literal
dynamic `import("kody:@...")` are not available. Package publish checks reject
Expand Down
42 changes: 41 additions & 1 deletion packages/worker/src/jobs/service.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2677,6 +2677,16 @@ test('executeJobOnce background execution workflow', async () => {
},
logs: ['storage helper executed'],
})
const packageInvocations =
await import('#worker/package-invocations/service.ts')
const executeInvokeSpy = vi.spyOn(
packageInvocations,
'createExecutePackageInvokeTools',
)
const runtimeInvokeSpy = vi.spyOn(
packageInvocations,
'createPackageRuntimeInvokeTools',
)

try {
const sessionClient = {
Expand Down Expand Up @@ -2772,11 +2782,24 @@ test('executeJobOnce background execution workflow', async () => {
}),
expect.any(Object),
expect.any(Object),
expect.any(Object),
expect.objectContaining({
storageTools: {
userId: callerContext.user.userId,
storageId: `job:${jobView.id}`,
writable: true,
},
packageInvokeTools: expect.objectContaining({
invoke: expect.any(Function),
}),
}),
)
expect(executeInvokeSpy).toHaveBeenCalledTimes(1)
expect(runtimeInvokeSpy).not.toHaveBeenCalled()
repoSessionRpcSpy.mockRestore()
} finally {
executeSpy.mockRestore()
executeInvokeSpy.mockRestore()
runtimeInvokeSpy.mockRestore()
}
}

Expand Down Expand Up @@ -4369,6 +4392,16 @@ test('executeJobOnce repo session bundling and check policy workflow', async ()
await import('#mcp/run-kody-registry.ts'),
'runBundledModuleWithRegistry',
)
const packageInvocations =
await import('#worker/package-invocations/service.ts')
const executeInvokeSpy = vi.spyOn(
packageInvocations,
'createExecutePackageInvokeTools',
)
const runtimeInvokeSpy = vi.spyOn(
packageInvocations,
'createPackageRuntimeInvokeTools',
)
const bundleSpy = vi.spyOn(
await import('#worker/package-runtime/module-graph.ts'),
'buildKodyModuleBundle',
Expand Down Expand Up @@ -4422,10 +4455,17 @@ test('executeJobOnce repo session bundling and check policy workflow', async ()
packageId: 'job-repo-module',
kodyId: 'repo-module-job',
},
packageInvokeTools: expect.objectContaining({
invoke: expect.any(Function),
}),
})
expect(runtimeInvokeSpy).toHaveBeenCalledTimes(1)
expect(executeInvokeSpy).not.toHaveBeenCalled()
} finally {
repoSessionRpcSpy.mockRestore()
executeSpy.mockRestore()
executeInvokeSpy.mockRestore()
runtimeInvokeSpy.mockRestore()
bundleSpy.mockRestore()
loadFilesSpy.mockRestore()
}
Expand Down
48 changes: 29 additions & 19 deletions packages/worker/src/jobs/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,27 +444,37 @@ async function executePublishedJobArtifact(input: {
}
: {}),
}
// Avoid a top-level jobs -> package-invocations cycle during capability
// registry initialization. Standalone jobs use the execute invoke path
// (same as inline workflows without package context); package-owned jobs
// keep package-runtime provenance so nested invoke/events stay in-package.
const {
createExecutePackageInvokeTools,
createPackageEventTools,
createPackageRuntimeInvokeTools,
} = await import('#worker/package-invocations/service.ts')
const sharedInvokeInput = {
env: input.env,
baseUrl: input.callerContext.baseUrl,
callerContext,
parentRunRecord: runRecord,
packageInvokeDepth: 0,
waitUntil: input.waitUntil,
}
const packageRuntimeTools = packageContext
? await (async () => {
// Avoid a top-level jobs -> package-invocations cycle during capability
// registry initialization.
const { createPackageEventTools, createPackageRuntimeInvokeTools } =
await import('#worker/package-invocations/service.ts')
const sharedInput = {
env: input.env,
baseUrl: input.callerContext.baseUrl,
callerContext,
? {
packageInvokeTools: createPackageRuntimeInvokeTools({
...sharedInvokeInput,
packageContext,
parentRunRecord: runRecord,
packageInvokeDepth: 0,
waitUntil: input.waitUntil,
}
return {
packageInvokeTools: createPackageRuntimeInvokeTools(sharedInput),
packageEventTools: createPackageEventTools(sharedInput),
}
})()
: null
}),
packageEventTools: createPackageEventTools({
...sharedInvokeInput,
packageContext,
}),
}
: {
packageInvokeTools: createExecutePackageInvokeTools(sharedInvokeInput),
}
return await runBundledModuleWithRegistry(
input.env,
callerContext,
Expand Down
2 changes: 1 addition & 1 deletion packages/worker/src/mcp/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1539,7 +1539,7 @@ const unboundRuntimeHelperNextSteps: Record<string, string> = {
storage:
"If this code belongs to a saved package that owns its data, use `packageStorage()` from 'kody:runtime' inside that package's module instead of ambient `storage`: it always reaches the declaring package's own bucket, in the package's own runtime and when statically imported into another context. Otherwise ambient `storage` is only bound when the call provides durable storage: retry the execute call with a `storageId` to bind a caller-owned bucket. To work with another package's data, call that package's export with keyless `packages.invoke({ kodyId, exportName, params })` so it runs in the package's own runtime context. Code that must also run without storage can guard with `if (storage) { ... }`.",
packages:
'`packages` is bound for authenticated ad hoc execute calls and saved-package runtime contexts; retry the call as an authenticated user, or guard with `if (packages) { ... }` where dynamic package invocation is optional.',
'`packages` is bound for authenticated ad hoc execute calls, standalone scheduled jobs, and saved-package runtime contexts; retry the call as an authenticated user or from a scheduled job, or guard with `if (packages) { ... }` where dynamic package invocation is optional.',
events:
"`events` is only bound in saved-package runtime contexts that can dispatch package events; call the owning package's export with keyless `packages.invoke` so it runs in that context, or guard with `if (events) { ... }`.",
packageSecrets:
Expand Down
Loading