Skip to content

Commit 29af6b9

Browse files
feat(install): load the installed pack's operator .env in emitted MCP entries, hook wrappers, and the artifact CLI (#469)
1 parent dacb5b0 commit 29af6b9

30 files changed

Lines changed: 912 additions & 22 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'agent-bundle': patch
3+
---
4+
5+
Give installed packs the operator `.env` layer that only `agent-bundle mcp run` had: every emitted stdio MCP entry, 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. (#PR)

‎docs/diagnostics.md‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ even when no error diagnostic was reported.
3636
| `AB7010`–`AB7013` | npm prepack inventory, artifact freshness, package bin targets, and release-version agreement. |
3737
| `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). |
3838
| `AB7xxx` | Project preparation and development rebuilds. |
39-
| `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"). |
39+
| `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"). |
4040
| `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. |
4141
| `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). |
4242
| `AB8215`–`AB8218` | Workbench read-only host discovery route. |
@@ -788,6 +788,19 @@ SQLite lock or shared-memory files.
788788
| --- | --- | --- |
789789
| `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. |
790790

791+
## Read-only Doctor operator env inventory (`AB7331`)
792+
793+
An installed pack's shells read `<plugin root>/.env` and `.env.local` at
794+
launch (#469) to fill variables the host did not set. Doctor reports whether
795+
those files are present and how many variables each declares — never a name
796+
or a value — so an operator can see that a credential-configured pack is, or
797+
is not, configured. Absent files are the normal case and produce no
798+
diagnostic.
799+
800+
| Code | Severity | Trigger |
801+
| --- | --- | --- |
802+
| `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. |
803+
791804
## Read-only runtime identity introspection (`AB7317`–`AB7318`)
792805

793806
| Code | Severity | Trigger |

‎docs/entry-conventions.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1165,6 +1165,22 @@ canonical precedence order (highest wins):
11651165
| 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. |
11661166
| 1 (lowest) | Manifest env | Entries declared in the server config plus the injected plugin-root anchor, path tokens expanded. |
11671167

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

11701186
Under `mcp run` the artifact is an ephemeral build product, so both

‎packages/agent-bundle/package.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@
6868
"types": "./dist/meta.d.ts",
6969
"import": "./dist/meta.js"
7070
},
71+
"./launch-env": {
72+
"types": "./dist/launch-env.d.ts",
73+
"import": "./dist/launch-env.js"
74+
},
7175
"./mcp-entry": {
7276
"types": "./dist/mcp-entry.d.ts",
7377
"import": "./dist/mcp-entry.js"

‎packages/agent-bundle/rslib.config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export default defineConfig({
102102
'event-project': './src/events/project.ts',
103103
index: './src/index.ts',
104104
'install-entry': './src/install-entry.ts',
105+
'launch-env': './src/launch-env.ts',
105106
'lifecycle-render-child': './src/dev/playground/lifecycle-render-child.ts',
106107
'mcp-apps': './src/mcp-apps.ts',
107108
'mcp-entry': './src/mcp-entry.ts',

‎packages/agent-bundle/src/adapters/hook-contract.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Diagnostic } from '../core/diagnostics.ts';
22
import { dataArrayValues, hasDataKeys, isPlainDataRecord, isRecord, ownDataValue } from '../core/strict-json.ts';
33
import { escapeRegExp } from '../core/strings.ts';
4+
import { operatorEnvImports, operatorEnvStatement } from '../build/launch-env-shell.ts';
45
import type { CanonicalAgentEvent } from '../routes/public.ts';
56
import {
67
canonicalHookEvents,
@@ -633,6 +634,10 @@ const eventRouteHookWrapperSource = (
633634
: ['const target = artifactTarget;'];
634635
return [
635636
"import { dirname, resolve } from 'node:path';",
637+
// Only a wrapper that can render in-process needs the operator `.env`
638+
// layer (#469): a shared-runtime wrapper forwards the event to the warm
639+
// MCP process, which applied the layer itself when it started.
640+
...(standalone ? operatorEnvImports({ importsFileUrlToPath: retiresLineage }) : []),
636641
...(standalone ? ["import { Worker } from 'node:worker_threads';"] : []),
637642
...(standalone
638643
? ["import { agent, available, createAgentRenderDispatcher, resolveStandaloneLineage, runAgentRequest, unavailable } from '@agent-bundle/runtime';"]
@@ -659,6 +664,7 @@ const eventRouteHookWrapperSource = (
659664
`const fallbackMode = ${JSON.stringify(route.fallback)};`,
660665
`const timeoutMs = ${String(entry.hook.timeoutMs ?? 5_000)};`,
661666
"const endpointId = `${artifactEpoch}:${artifactTarget}:${dirname(dirname(resolve(process.argv[1])))}`;",
667+
...(standalone ? [operatorEnvStatement] : []),
662668
'',
663669
'const fail = (message) => { throw new Error(`Agent Bundle event route error: ${message}`); };',
664670
...(standalone
@@ -794,7 +800,11 @@ const eventRouteHookWrapperSource = (
794800

795801
/** Emits the published Cursor hook wrapper source; see encodeCursorPlaygroundInput for the envelope contract. */
796802
export const cursorHookWrapperSource = (entry: TargetHookWrapper): string => [
803+
...operatorEnvImports({ importsFileUrlToPath: false }),
797804
`import * as handlerModule from ${JSON.stringify(entry.hook.source)};`,
805+
// The installed pack's operator `.env` layer (#469), applied before the
806+
// handler runs; the handler module itself is a static import.
807+
operatorEnvStatement,
798808
'const target = "cursor";',
799809
`const canonicalEvent = ${JSON.stringify(entry.event)};`,
800810
`const nativeEvent = ${JSON.stringify(entry.nativeEvent)};`,
@@ -1209,7 +1219,11 @@ export const nativeHookWrapperSource = (
12091219
]
12101220
: [`const target = ${JSON.stringify(entry.target)};`];
12111221
return [
1222+
...operatorEnvImports({ importsFileUrlToPath: false }),
12121223
`import * as handlerModule from ${JSON.stringify(entry.hook.source)};`,
1224+
// The installed pack's operator `.env` layer (#469), applied before the
1225+
// handler runs; the handler module itself is a static import.
1226+
operatorEnvStatement,
12131227
...targetSource,
12141228
`const canonicalEvent = ${JSON.stringify(entry.event)};`,
12151229
`const nativeEvent = ${JSON.stringify(nativeEvent)};`,

‎packages/agent-bundle/src/build/cli-bins.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ import type { Diagnostic } from '../core/diagnostics.ts';
77
import type { NormalizedBinEntry, NormalizedPlugin } from '../core/types.ts';
88
import { resolveArtifactDestination } from './emit.ts';
99
import { runtimeIgnoredRoot, type CompiledEntry } from './entries.ts';
10+
import { launchEnvRuntimeSpecifier } from './launch-env-shell.ts';
1011
import {
1112
cliEntryRuntimePath,
1213
cliEntryRuntimeSpecifier,
1314
generatedCliBinEntrySource,
1415
generatedRenderedRouteWorkerSource,
16+
launchEnvRuntimePath,
1517
} from './entry-shell.ts';
1618
import type { RslibEntry, RslibSurfacePlan } from './rslib.ts';
1719

@@ -111,7 +113,8 @@ export const cliBinRslibEntries = (
111113
const cli = generatedCli(entry.bin);
112114
const workerFile = `${entry.name}-flight.mjs`;
113115
const entries: RslibEntry[] = [{
114-
aliases: { [cliEntryRuntimeSpecifier]: cliEntryRuntimePath() },
116+
// The artifact-hosted bin applies the pack's operator `.env` layer (#469).
117+
aliases: { [cliEntryRuntimeSpecifier]: cliEntryRuntimePath(), [launchEnvRuntimeSpecifier]: launchEnvRuntimePath() },
115118
name: `bin-${entry.name}`,
116119
outputRelativePath: cliBinArtifactPath(entry.name),
117120
...(entry.rendered ? { rscManifest: true as const } : {}),
@@ -193,7 +196,7 @@ export const planCliBinsSurface = (
193196
}),
194197
})));
195198
},
196-
ignoredSourcePaths: [runtimeIgnoredRoot(cliEntryRuntimePath())],
199+
ignoredSourcePaths: [runtimeIgnoredRoot(cliEntryRuntimePath()), launchEnvRuntimePath()],
197200
logLevel: 'error',
198201
};
199202
};

‎packages/agent-bundle/src/build/entries.ts‎

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ import { stableJson } from '../core/digest.ts';
2323
import { emitPlanEntries, resolveArtifactDestination } from './emit.ts';
2424
import { scanEntryExports } from './entry-exports.ts';
2525
import { deepFreeze } from '../core/freeze.ts';
26+
import { launchEnvRuntimeSpecifier } from './launch-env-shell.ts';
2627
import {
2728
cliEntryRuntimePath,
29+
launchEnvRuntimePath,
2830
cliEntryRuntimeSpecifier,
2931
generatedExecutableEntrySource,
3032
generatedRenderedRouteWorkerSource,
@@ -414,6 +416,9 @@ export const planMcpEntriesSurface = async (
414416
: undefined;
415417
}));
416418
const runtimeShell = entryShells.some((shell) => shell !== undefined) ? mcpEntryRuntimePath() : undefined;
419+
// The stdio lifecycle shell applies the operator `.env` layer (#469); the
420+
// plain-Node module is inlined beside it.
421+
const launchEnvRuntime = runtimeShell === undefined ? undefined : launchEnvRuntimePath();
417422
const eventIpcRuntime = options.eventHooks.length === 0 ? undefined : eventRuntimeModulePath('ipc');
418423
const eventProjectRuntime = options.eventHooks.length === 0 ? undefined : eventRuntimeModulePath('project');
419424
const serverRuntime = generatedRouteSources.some((routeSource) => routeSource !== undefined)
@@ -425,6 +430,7 @@ export const planMcpEntriesSurface = async (
425430
: {
426431
aliases: {
427432
[mcpEntryRuntimeSpecifier]: runtimeShell,
433+
...(launchEnvRuntime === undefined ? {} : { [launchEnvRuntimeSpecifier]: launchEnvRuntime }),
428434
...(id !== eventHostId || eventIpcRuntime === undefined || eventProjectRuntime === undefined
429435
? {}
430436
: {
@@ -475,6 +481,7 @@ export const planMcpEntriesSurface = async (
475481
: {
476482
ignoredSourcePaths: [
477483
...(runtimeShell === undefined ? [] : [runtimeShell]),
484+
...(launchEnvRuntime === undefined ? [] : [launchEnvRuntime]),
478485
...(eventIpcRuntime === undefined ? [] : [runtimeIgnoredRoot(eventIpcRuntime)]),
479486
...(serverRuntime === undefined ? [] : [runtimeIgnoredRoot(serverRuntime)]),
480487
],
@@ -549,6 +556,7 @@ export const planHooksSurface = (
549556
const workerArtifactEpoch = generatedRouteArtifactEpoch(options.plugin);
550557
const eventIpcRuntime = routeEntries.length === 0 ? undefined : eventRuntimeModulePath('ipc');
551558
const eventProjectRuntime = routeEntries.length === 0 ? undefined : eventRuntimeModulePath('project');
559+
const launchEnvRuntime = launchEnvRuntimePath();
552560
const workerEntry = standaloneEventRoutes.length === 0
553561
? undefined
554562
: {
@@ -587,14 +595,16 @@ export const planHooksSurface = (
587595
|| entries[index]!.hook.eventRoute?.fallback === 'standalone'
588596
? { rscManifest: true as const }
589597
: {}),
590-
...(entries[index]!.hook.eventRoute === undefined || eventIpcRuntime === undefined
591-
? {}
592-
: {
593-
aliases: {
598+
aliases: {
599+
// Every wrapper that runs plugin code applies the operator `.env` layer (#469).
600+
[launchEnvRuntimeSpecifier]: launchEnvRuntime,
601+
...(entries[index]!.hook.eventRoute === undefined || eventIpcRuntime === undefined
602+
? {}
603+
: {
594604
[eventIpcRuntimeSpecifier]: eventIpcRuntime,
595605
...(eventProjectRuntime === undefined ? {} : { [eventProjectRuntimeSpecifier]: eventProjectRuntime }),
596-
},
597-
}),
606+
}),
607+
},
598608
source: entry.source,
599609
sourceInputs: entry.sourceInputs,
600610
virtualSource: entries[index]!.virtualSource
@@ -603,11 +613,10 @@ export const planHooksSurface = (
603613
})),
604614
...(workerEntry === undefined ? [] : [workerEntry]),
605615
],
606-
...(eventIpcRuntime === undefined
607-
? {}
608-
: {
609-
ignoredSourcePaths: [runtimeIgnoredRoot(eventIpcRuntime)],
610-
}),
616+
ignoredSourcePaths: [
617+
launchEnvRuntime,
618+
...(eventIpcRuntime === undefined ? [] : [runtimeIgnoredRoot(eventIpcRuntime)]),
619+
],
611620
finish: async (evidence) => {
612621
const evidenceByPath = new Map(evidence.map((entry) => [entry.path, entry.sourceInputs]));
613622
return Object.freeze(compiled.map((entry, index) => Object.freeze({

‎packages/agent-bundle/src/build/entry-shell.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
22
import { fileURLToPath } from 'node:url';
33

44
import { eventIpcRuntimeSpecifier, eventProjectRuntimeSpecifier } from '../adapters/hook-contract.ts';
5+
import { operatorEnvImports, operatorEnvStatement } from './launch-env-shell.ts';
56
import type { NoticeDeliveryAdvertisement } from '../adapters/notice-delivery.ts';
67
import { stableJson } from '../core/digest.ts';
78
import type { NormalizedHook, NormalizedNoticeRetentionPolicy, NormalizedStateDefinition } from '../core/types.ts';
@@ -52,17 +53,24 @@ export const mcpEntryRuntimePath = (): string => runtimeModulePath('mcp-entry');
5253

5354
export const mcpServerRuntimePath = (): string => runtimeModulePath('mcp-server-runtime');
5455

56+
/** The on-disk `agent-bundle/launch-env` module (#469), aliased into every artifact shell that applies the operator `.env` layer. */
57+
export const launchEnvRuntimePath = (): string => runtimeModulePath('launch-env');
58+
5559
/**
5660
* The generated stdio MCP entry body for a factory-exporting server module:
5761
* the lifecycle installs the console guard before the consumer module
58-
* evaluates, so `loadEntry` stays a deferred dynamic import.
62+
* evaluates, so `loadEntry` stays a deferred dynamic import — which is also
63+
* what lets the operator `.env` layer land before any server code reads
64+
* `process.env`.
5965
*/
6066
export const generatedStdioMcpEntrySource = (options: {
6167
readonly entrySource: string;
6268
readonly serverName: string;
6369
}): string => [
70+
...operatorEnvImports({ importsFileUrlToPath: false }),
6471
`import { runGeneratedStdioMcpEntry } from ${JSON.stringify(mcpEntryRuntimeSpecifier)};`,
6572
'',
73+
operatorEnvStatement,
6674
'await runGeneratedStdioMcpEntry({',
6775
` loadEntry: () => import(${JSON.stringify(options.entrySource)}),`,
6876
` serverName: ${JSON.stringify(options.serverName)},`,
@@ -307,9 +315,19 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions)
307315
: "import { available, runAgentRequest, unavailable } from '@agent-bundle/runtime';",
308316
...(rendered ? ["import { Worker } from 'node:worker_threads';"] : []),
309317
...generatedStateImports(options.state, stateFallback),
318+
// The artifact-hosted executable is part of an installed pack, so it
319+
// reads the pack's operator `.env` layer (#469); the npm package bin
320+
// runs from the operator's own shell and reads none.
321+
...(stateFallback === 'artifact'
322+
? operatorEnvImports({ importsFileUrlToPath: options.state?.lifetime === 'workspace-durable' })
323+
: []),
310324
...routeImports(commandRoutes),
311325
...providerImports(providers),
312326
'',
327+
// Before the state owner opens and before any command runs; route modules
328+
// are static imports, so a module-level `process.env` read still sees the
329+
// host environment only (documented).
330+
...(stateFallback === 'artifact' ? [operatorEnvStatement] : []),
313331
...generatedStateOwner(options.state, stateFallback, options),
314332
'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };',
315333
...providerRegistrySource(providers),

0 commit comments

Comments
 (0)