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
5 changes: 5 additions & 0 deletions .changeset/469-operator-env-installed-packs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'agent-bundle': patch
---

Give installed packs the operator `.env` layer that only `agent-bundle mcp run` had: every emitted stdio MCP entry with a factory default export (a self-connecting entry keeps its byte-identical body; `agent-bundle/launch-env` is aliased into every stdio entry, so such an entry imports and applies `applyOperatorEnv` itself if it wants the layer), every hook wrapper that runs plugin code, and the artifact CLI `bin/<name>.mjs` read `<plugin root>/.env` then `.env.local` at launch (the plugin root is the expanded `AGENT_BUNDLE_PLUGIN_ROOT`, else the shell's parent directory), filling only variables the host did not set — host environment and manifest `env` win, `.env.local` beats `.env`, values are never logged, a missing file costs nothing, an unreadable one is skipped. `AGENT_BUNDLE_ENV_FILE` names the file(s) to read instead (platform path delimiter; `none` disables the layer), and `mcp run` hands `--env-file` / `--no-env` down through it so a rehearsal and an installed pack read the same files. The loader is the new plain-Node `agent-bundle/launch-env` module (`applyOperatorEnv`, `parseOperatorEnv`), inlined into the shells without Effect. `agent-bundle doctor` reports whether an installed copy carries `.env` / `.env.local` and how many variables each declares (`AB7331`, informational; a warning when the file cannot be read), never a name or a value. The npm package bin reads no pack file. Fixes #469. (#538)
15 changes: 14 additions & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ even when no error diagnostic was reported.
| `AB7010`–`AB7013` | npm prepack inventory, artifact freshness, package bin targets, and release-version agreement. |
| `AB7200`–`AB7202`, `AB7210`–`AB7211` | Development rebuilds and live host surfaces: rebuild admission and phase failures, development host install sync, and the dev-epoch contract gate (see below). |
| `AB7xxx` | Project preparation and development rebuilds. |
| `AB7300`–`AB7330` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7325`; see below), the Cursor Agent Plugins launch proof (`AB7326`; see below), a disabled Claude install (`AB7327`; see below), and lifecycle receipts and activation states (`AB7328`–`AB7330`; see below). `AB7311` and `AB7325` are also emitted by `build` and `validate --artifact` from the Claude load check (see "Claude Code host validation"). |
| `AB7300`–`AB7331` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7325`; see below), the Cursor Agent Plugins launch proof (`AB7326`; see below), a disabled Claude install (`AB7327`; see below), lifecycle receipts and activation states (`AB7328`–`AB7330`; see below), and the operator `.env` layer of an installed pack (`AB7331`; see below). `AB7311` and `AB7325` are also emitted by `build` and `validate --artifact` from the Claude load check (see "Claude Code host validation"). |
| `AB8200`–`AB8209` | Workbench development runtime routes (`/api/runtime/**`): `AB8200` development runtime provider configuration, load, or lifecycle failure, `AB8201` runtime/session/run not available, `AB8202` invalid route path, `AB8203` invalid request shape, `AB8204` stale runtime generation or MCP session revision (409), `AB8205` runtime request could not be completed, `AB8206` Workbench runtime client failure, `AB8207` Agent Document decoding needs the optional `@agent-bundle/runtime` peer (503), `AB8208` stored Flight could not be decoded as an Agent Document (409), `AB8209` decoded Agent Document over the 16 MiB budget (413) or an invalid document response. |
| `AB8210`–`AB8214` | Workbench semantic lifecycle replay routes (`/api/lifecycles`, `/api/lifecycles/replays`): `AB8210` invalid path, `AB8211` malformed replay request or native envelope (400, carries the shared validator message), `AB8212` replay unavailable or could not be completed, `AB8213` stale manifest binding (409; the page repairs it with refresh → explicit re-run), `AB8214` replay over the 16 MiB budget (413). |
| `AB8215`–`AB8218` | Workbench read-only host discovery route. |
Expand Down Expand Up @@ -791,6 +791,19 @@ SQLite lock or shared-memory files.
| --- | --- | --- |
| `AB7316` | warning | An installed bundle's `state/` directory or one of its `*.sqlite`, `-wal`, or `-shm` files cannot be read with filesystem metadata operations. Repair permissions and rerun Doctor; Doctor never repairs state. |

## Read-only Doctor operator env inventory (`AB7331`)

An installed pack's shells read `<plugin root>/.env` and `.env.local` at
launch (#469) to fill variables the host did not set. Doctor reports whether
those files are present and how many variables each declares — never a name
or a value — so an operator can see that a credential-configured pack is, or
is not, configured. Absent files are the normal case and produce no
diagnostic.

| Code | Severity | Trigger |
| --- | --- | --- |
| `AB7331` | info / warning | Info: an installed copy (or the `--from` bundle) carries `.env` or `.env.local` at its plugin root; the message names the file and its variable count. Warning: the file exists but cannot be read, so the pack's shells skip it at launch — repair its permissions and rerun Doctor. |

## Read-only runtime identity introspection (`AB7317`–`AB7318`)

| Code | Severity | Trigger |
Expand Down
24 changes: 23 additions & 1 deletion docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -1013,7 +1013,12 @@ race against wedged transports, and heartbeat/activity logging on stderr
name).

Self-connecting entries — modules that construct and connect a transport at
top level without a default export — keep today's behavior byte for byte.
top level without a default export — keep today's behavior byte for byte: no
lifecycle shell and no operator `.env` layer (#469); an entry that wants the
layer calls `applyOperatorEnv` from `agent-bundle/launch-env` itself. That
module is aliased into every stdio entry, shell or not, so the import is
inlined from this package rather than resolved through the plugin's own
`node_modules`, and a `tools` hatch can never externalize it.

Every served tool call is one ordinary `tools/call`: optional
`notifications/progress` while the caller's progress token is live, then one
Expand Down Expand Up @@ -1347,6 +1352,23 @@ canonical precedence order (highest wins):
| 2 | `.env` file layer | The conventional project-root set, or the explicit `--env-file` list in order. Fills gaps only; never beats an exported variable. |
| 1 (lowest) | Manifest env | Entries declared in the server config plus the injected plugin-root anchor, path tokens expanded. |

Installed packs get the same layer without `mcp run` (#469): every artifact
shell that runs plugin code — the stdio MCP entry of a factory-exporting
server (before its deferred server import; a self-connecting entry has no
shell), the hook wrappers that execute handlers or render standalone, and the
artifact CLI `bin/<name>.mjs` — applies `agent-bundle/launch-env`
(`src/launch-env.ts`, plain Node, inlined into the bundle) at startup. It
reads `<plugin root>/.env` then `.env.local`, where the plugin root is the
expanded `AGENT_BUNDLE_PLUGIN_ROOT` or the shell's parent directory, or the
files `AGENT_BUNDLE_ENV_FILE` names (platform-delimited list; `none` disables
the layer); it fills only variables the host did not set, never logs a value,
treats a missing file as the normal case and an unreadable one as skipped.
The dotenv grammar has no `${VAR}` interpolation. Under `mcp run` the plugin
root is the project root, so the shell's pass is a no-op; `--env-file` and
`--no-env` are handed down as `AGENT_BUNDLE_ENV_FILE` so the shell follows the
operator's choice. The npm package bin reads no pack file. Doctor reports the
presence and variable count of each file (`AB7331`).

### Durable-state anchors

Under `mcp run` the artifact is an ephemeral build product, so both
Expand Down
4 changes: 4 additions & 0 deletions packages/agent-bundle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@
"types": "./dist/meta.d.ts",
"import": "./dist/meta.js"
},
"./launch-env": {
"types": "./dist/launch-env.d.ts",
"import": "./dist/launch-env.js"
},
"./mcp-entry": {
"types": "./dist/mcp-entry.d.ts",
"import": "./dist/mcp-entry.js"
Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export default defineConfig({
'event-project': './src/events/project.ts',
index: './src/index.ts',
'install-entry': './src/install-entry.ts',
'launch-env': './src/launch-env.ts',
'lifecycle-render-child': './src/dev/playground/lifecycle-render-child.ts',
'mcp-apps': './src/mcp-apps.ts',
'mcp-entry': './src/mcp-entry.ts',
Expand Down
14 changes: 14 additions & 0 deletions packages/agent-bundle/src/adapters/hook-contract.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Diagnostic } from '../core/diagnostics.ts';
import { dataArrayValues, hasDataKeys, isPlainDataRecord, isRecord, ownDataValue } from '../core/strict-json.ts';
import { escapeRegExp } from '../core/strings.ts';
import { operatorEnvImports, operatorEnvStatement } from '../build/launch-env-shell.ts';
import type { CanonicalAgentEvent } from '../routes/public.ts';
import {
canonicalHookEvents,
Expand Down Expand Up @@ -649,6 +650,10 @@ const eventRouteHookWrapperSource = (
: ['const target = artifactTarget;'];
return [
"import { dirname, resolve } from 'node:path';",
// Only a wrapper that can render in-process needs the operator `.env`
// layer (#469): a shared-runtime wrapper forwards the event to the warm
// MCP process, which applied the layer itself when it started.
...(standalone ? operatorEnvImports({ importsFileUrlToPath: retiresLineage }) : []),
...(standalone ? ["import { Worker } from 'node:worker_threads';"] : []),
...(standalone
? [
Expand Down Expand Up @@ -676,6 +681,7 @@ const eventRouteHookWrapperSource = (
`const fallbackMode = ${JSON.stringify(route.fallback)};`,
`const timeoutMs = ${String(entry.hook.timeoutMs ?? 5_000)};`,
"const endpointId = `${artifactEpoch}:${artifactTarget}:${dirname(dirname(resolve(process.argv[1])))}`;",
...(standalone ? [operatorEnvStatement] : []),
'',
'const fail = (message) => { throw new Error(`Agent Bundle event route error: ${message}`); };',
...(standalone
Expand Down Expand Up @@ -818,7 +824,11 @@ const eventRouteHookWrapperSource = (

/** Emits the published Cursor hook wrapper source; see encodeCursorPlaygroundInput for the envelope contract. */
export const cursorHookWrapperSource = (entry: TargetHookWrapper): string => [
...operatorEnvImports({ importsFileUrlToPath: false }),
`import * as handlerModule from ${JSON.stringify(entry.hook.source)};`,
// The installed pack's operator `.env` layer (#469), applied before the
// handler runs; the handler module itself is a static import.
operatorEnvStatement,
'const target = "cursor";',
`const canonicalEvent = ${JSON.stringify(entry.event)};`,
`const nativeEvent = ${JSON.stringify(entry.nativeEvent)};`,
Expand Down Expand Up @@ -1233,7 +1243,11 @@ export const nativeHookWrapperSource = (
]
: [`const target = ${JSON.stringify(entry.target)};`];
return [
...operatorEnvImports({ importsFileUrlToPath: false }),
`import * as handlerModule from ${JSON.stringify(entry.hook.source)};`,
// The installed pack's operator `.env` layer (#469), applied before the
// handler runs; the handler module itself is a static import.
operatorEnvStatement,
...targetSource,
`const canonicalEvent = ${JSON.stringify(entry.event)};`,
`const nativeEvent = ${JSON.stringify(nativeEvent)};`,
Expand Down
7 changes: 5 additions & 2 deletions packages/agent-bundle/src/build/cli-bins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import type { Diagnostic } from '../core/diagnostics.ts';
import type { NormalizedBinEntry, NormalizedPlugin } from '../core/types.ts';
import { resolveArtifactDestination } from './emit.ts';
import { runtimeIgnoredRoot, type CompiledEntry } from './entries.ts';
import { launchEnvRuntimeSpecifier } from './launch-env-shell.ts';
import {
cliEntryRuntimePath,
cliEntryRuntimeSpecifier,
generatedCliBinEntrySource,
generatedRenderedRouteWorkerSource,
launchEnvRuntimePath,
} from './entry-shell.ts';
import type { RslibEntry, RslibSurfacePlan } from './rslib.ts';

Expand Down Expand Up @@ -111,7 +113,8 @@ export const cliBinRslibEntries = (
const cli = generatedCli(entry.bin);
const workerFile = `${entry.name}-flight.mjs`;
const entries: RslibEntry[] = [{
aliases: { [cliEntryRuntimeSpecifier]: cliEntryRuntimePath() },
// The artifact-hosted bin applies the pack's operator `.env` layer (#469).
aliases: { [cliEntryRuntimeSpecifier]: cliEntryRuntimePath(), [launchEnvRuntimeSpecifier]: launchEnvRuntimePath() },
name: `bin-${entry.name}`,
outputRelativePath: cliBinArtifactPath(entry.name),
...(entry.rendered ? { rscManifest: true as const } : {}),
Expand Down Expand Up @@ -193,7 +196,7 @@ export const planCliBinsSurface = (
}),
})));
},
ignoredSourcePaths: [runtimeIgnoredRoot(cliEntryRuntimePath())],
ignoredSourcePaths: [runtimeIgnoredRoot(cliEntryRuntimePath()), runtimeIgnoredRoot(launchEnvRuntimePath())],
logLevel: 'error',
};
};
Expand Down
66 changes: 39 additions & 27 deletions packages/agent-bundle/src/build/entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ import { stableJson } from '../core/digest.ts';
import { emitPlanEntries, resolveArtifactDestination } from './emit.ts';
import { scanEntryExports } from './entry-exports.ts';
import { deepFreeze } from '../core/freeze.ts';
import { launchEnvRuntimeSpecifier } from './launch-env-shell.ts';
import {
cliEntryRuntimePath,
launchEnvRuntimePath,
cliEntryRuntimeSpecifier,
generatedExecutableEntrySource,
generatedRenderedRouteWorkerSource,
Expand Down Expand Up @@ -425,16 +427,25 @@ export const planMcpEntriesSurface = async (
: undefined;
}));
const runtimeShell = entryShells.some((shell) => shell !== undefined) ? mcpEntryRuntimePath() : undefined;
// The operator `.env` layer (#469) is public API for every stdio entry: the
// lifecycle shell applies it before the deferred server import, and a
// self-connecting entry — which has no shell — imports
// `agent-bundle/launch-env` and calls `applyOperatorEnv` itself. The alias
// is unconditional so that import resolves to this package's plain-Node
// module and can never be externalized; the bundler inlines it only where
// an import reaches it, so an entry that never imports it is unchanged.
const launchEnvRuntime = launchEnvRuntimePath();
const eventIpcRuntime = options.eventHooks.length === 0 ? undefined : eventRuntimeModulePath('ipc');
const eventProjectRuntime = options.eventHooks.length === 0 ? undefined : eventRuntimeModulePath('project');
const serverRuntime = generatedRouteSources.some((routeSource) => routeSource !== undefined)
? mcpServerRuntimePath()
: undefined;
const mainEntries = compiled.map(({ id, name, source, sourceInputs }, index) => ({
...(entryShells[index] === undefined || runtimeShell === undefined
? {}
: {
aliases: {
aliases: {
[launchEnvRuntimeSpecifier]: launchEnvRuntime,
...(entryShells[index] === undefined || runtimeShell === undefined
? {}
: {
[mcpEntryRuntimeSpecifier]: runtimeShell,
...(id !== eventHostId || eventIpcRuntime === undefined || eventProjectRuntime === undefined
? {}
Expand All @@ -445,9 +456,9 @@ export const planMcpEntriesSurface = async (
...(generatedRouteSources[index] === undefined || serverRuntime === undefined
? {}
: { [mcpServerRuntimeSpecifier]: serverRuntime }),
},
virtualSource: entryShells[index],
}),
}),
},
...(entryShells[index] === undefined ? {} : { virtualSource: entryShells[index] }),
name,
outputRelativePath: `mcp/${name}.mjs`,
...(generatedRouteSources[index] === undefined ? {} : { rscManifest: true as const }),
Expand Down Expand Up @@ -481,15 +492,14 @@ export const planMcpEntriesSurface = async (
});
return {
entries: [...mainEntries, ...workerEntries],
...([runtimeShell, eventIpcRuntime, serverRuntime].filter((path): path is string => path !== undefined).length === 0
? {}
: {
ignoredSourcePaths: [
...(runtimeShell === undefined ? [] : [runtimeShell]),
...(eventIpcRuntime === undefined ? [] : [runtimeIgnoredRoot(eventIpcRuntime)]),
...(serverRuntime === undefined ? [] : [runtimeIgnoredRoot(serverRuntime)]),
],
}),
ignoredSourcePaths: [
...(runtimeShell === undefined ? [] : [runtimeShell]),
// The package root, not the file: from the built package the
// module shares chunks with its siblings under `dist/`.
runtimeIgnoredRoot(launchEnvRuntime),
...(eventIpcRuntime === undefined ? [] : [runtimeIgnoredRoot(eventIpcRuntime)]),
...(serverRuntime === undefined ? [] : [runtimeIgnoredRoot(serverRuntime)]),
],
finish: async (evidence) => {
const evidenceByPath = new Map(evidence.map((entry) => [entry.path, entry.sourceInputs]));
return Object.freeze(compiled.map((entry) => Object.freeze({
Expand Down Expand Up @@ -560,6 +570,7 @@ export const planHooksSurface = (
const workerArtifactEpoch = generatedRouteArtifactEpoch(options.plugin);
const eventIpcRuntime = routeEntries.length === 0 ? undefined : eventRuntimeModulePath('ipc');
const eventProjectRuntime = routeEntries.length === 0 ? undefined : eventRuntimeModulePath('project');
const launchEnvRuntime = launchEnvRuntimePath();
const workerEntry = standaloneEventRoutes.length === 0
? undefined
: {
Expand Down Expand Up @@ -598,14 +609,16 @@ export const planHooksSurface = (
|| entries[index]!.hook.eventRoute?.fallback === 'standalone'
? { rscManifest: true as const }
: {}),
...(entries[index]!.hook.eventRoute === undefined || eventIpcRuntime === undefined
? {}
: {
aliases: {
aliases: {
// Every wrapper that runs plugin code applies the operator `.env` layer (#469).
[launchEnvRuntimeSpecifier]: launchEnvRuntime,
...(entries[index]!.hook.eventRoute === undefined || eventIpcRuntime === undefined
? {}
: {
[eventIpcRuntimeSpecifier]: eventIpcRuntime,
...(eventProjectRuntime === undefined ? {} : { [eventProjectRuntimeSpecifier]: eventProjectRuntime }),
},
}),
}),
},
source: entry.source,
sourceInputs: entry.sourceInputs,
virtualSource: entries[index]!.virtualSource
Expand All @@ -614,11 +627,10 @@ export const planHooksSurface = (
})),
...(workerEntry === undefined ? [] : [workerEntry]),
],
...(eventIpcRuntime === undefined
? {}
: {
ignoredSourcePaths: [runtimeIgnoredRoot(eventIpcRuntime)],
}),
ignoredSourcePaths: [
runtimeIgnoredRoot(launchEnvRuntime),
...(eventIpcRuntime === undefined ? [] : [runtimeIgnoredRoot(eventIpcRuntime)]),
],
finish: async (evidence) => {
const evidenceByPath = new Map(evidence.map((entry) => [entry.path, entry.sourceInputs]));
return Object.freeze(compiled.map((entry, index) => Object.freeze({
Expand Down
Loading
Loading