diff --git a/docs/contributing/packages-and-manifests.md b/docs/contributing/packages-and-manifests.md index 1f27566d2e..9e5c0a482b 100644 --- a/docs/contributing/packages-and-manifests.md +++ b/docs/contributing/packages-and-manifests.md @@ -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' @@ -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. diff --git a/docs/guides/package-lifecycle.md b/docs/guides/package-lifecycle.md index 6023beb317..d78fce6a49 100644 --- a/docs/guides/package-lifecycle.md +++ b/docs/guides/package-lifecycle.md @@ -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. diff --git a/docs/use/execute.md b/docs/use/execute.md index 3fb3efcb41..fa767128d3 100644 --- a/docs/use/execute.md +++ b/docs/use/execute.md @@ -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 @@ -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 diff --git a/docs/use/packages.md b/docs/use/packages.md index f5e8ceafbd..1b47fdfc6d 100644 --- a/docs/use/packages.md +++ b/docs/use/packages.md @@ -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 @@ -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 diff --git a/packages/worker/src/jobs/service.node.test.ts b/packages/worker/src/jobs/service.node.test.ts index 8e7b7f60c0..20c5b1bb1a 100644 --- a/packages/worker/src/jobs/service.node.test.ts +++ b/packages/worker/src/jobs/service.node.test.ts @@ -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 = { @@ -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() } } @@ -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', @@ -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() } diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index 6b047eaaa9..d84bbc337b 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -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, diff --git a/packages/worker/src/mcp/executor.ts b/packages/worker/src/mcp/executor.ts index 6c04c7b9aa..60efbe241d 100644 --- a/packages/worker/src/mcp/executor.ts +++ b/packages/worker/src/mcp/executor.ts @@ -1539,7 +1539,7 @@ const unboundRuntimeHelperNextSteps: Record = { 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: