From ecd03ae51444891008a1a2541f9f2d6871fb83db Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 06:54:59 +0000 Subject: [PATCH 1/6] feat(install): load the installed pack's operator .env in emitted MCP entries, hook wrappers, and the artifact CLI (#469) --- .../469-operator-env-installed-packs.md | 5 + docs/diagnostics.md | 15 +- docs/entry-conventions.md | 16 ++ packages/agent-bundle/package.json | 4 + packages/agent-bundle/rslib.config.ts | 1 + .../src/adapters/hook-contract.ts | 14 ++ packages/agent-bundle/src/build/cli-bins.ts | 7 +- packages/agent-bundle/src/build/entries.ts | 31 +-- .../agent-bundle/src/build/entry-shell.ts | 19 +- .../agent-bundle/src/build/inspect-bundler.ts | 4 + .../src/build/launch-env-shell.ts | 34 ++++ packages/agent-bundle/src/cli.ts | 10 + packages/agent-bundle/src/install/doctor.ts | 75 ++++++++ packages/agent-bundle/src/launch-env.ts | 178 ++++++++++++++++++ packages/agent-bundle/src/services/mcp-run.ts | 28 ++- packages/agent-bundle/tests/doctor.test.ts | 67 +++++++ .../agent-bundle/tests/entry-shell.test.ts | 49 +++++ packages/agent-bundle/tests/hooks.test.ts | 60 ++++++ .../agent-bundle/tests/launch-env.test.ts | 143 ++++++++++++++ .../agent-bundle/tests/package-build.test.ts | 6 + .../tests/packed-stdio-projection.test.ts | 48 ++++- website/docs/en/guide/authoring/mcp.mdx | 5 +- .../en/guide/distribution/installation.mdx | 45 ++++- website/docs/en/reference/api.mdx | 1 + .../docs/en/reference/runtime-environment.mdx | 16 ++ website/docs/zh/guide/authoring/mcp.mdx | 4 +- .../zh/guide/distribution/installation.mdx | 36 ++++ website/docs/zh/reference/api.mdx | 1 + .../docs/zh/reference/runtime-environment.mdx | 13 ++ website/rspress.config.ts | 1 + 30 files changed, 913 insertions(+), 23 deletions(-) create mode 100644 .changeset/469-operator-env-installed-packs.md create mode 100644 packages/agent-bundle/src/build/launch-env-shell.ts create mode 100644 packages/agent-bundle/src/launch-env.ts create mode 100644 packages/agent-bundle/tests/launch-env.test.ts diff --git a/.changeset/469-operator-env-installed-packs.md b/.changeset/469-operator-env-installed-packs.md new file mode 100644 index 000000000..e1de86b17 --- /dev/null +++ b/.changeset/469-operator-env-installed-packs.md @@ -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, every hook wrapper that runs plugin code, and the artifact CLI `bin/.mjs` read `/.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) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 3afc75b50..11cd3105a 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -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. | @@ -790,6 +790,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 `/.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 | diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 736960b60..79fede859 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1270,6 +1270,22 @@ 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 (before its deferred server +import), the hook wrappers that execute handlers or render standalone, and the +artifact CLI `bin/.mjs` — applies `agent-bundle/launch-env` +(`src/launch-env.ts`, plain Node, inlined into the bundle) at startup. It +reads `/.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 diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json index 5dfdeb2c9..bd521ec55 100644 --- a/packages/agent-bundle/package.json +++ b/packages/agent-bundle/package.json @@ -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" diff --git a/packages/agent-bundle/rslib.config.ts b/packages/agent-bundle/rslib.config.ts index d1cdedcf4..a9d400cd6 100644 --- a/packages/agent-bundle/rslib.config.ts +++ b/packages/agent-bundle/rslib.config.ts @@ -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', diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 608b2f939..f3ceeb942 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -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, @@ -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 ? [ @@ -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 @@ -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)};`, @@ -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)};`, diff --git a/packages/agent-bundle/src/build/cli-bins.ts b/packages/agent-bundle/src/build/cli-bins.ts index 5d5fc2bff..16efa6864 100644 --- a/packages/agent-bundle/src/build/cli-bins.ts +++ b/packages/agent-bundle/src/build/cli-bins.ts @@ -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'; @@ -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 } : {}), @@ -193,7 +196,7 @@ export const planCliBinsSurface = ( }), }))); }, - ignoredSourcePaths: [runtimeIgnoredRoot(cliEntryRuntimePath())], + ignoredSourcePaths: [runtimeIgnoredRoot(cliEntryRuntimePath()), launchEnvRuntimePath()], logLevel: 'error', }; }; diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 676116d20..e207046d1 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -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, @@ -425,6 +427,9 @@ export const planMcpEntriesSurface = async ( : undefined; })); const runtimeShell = entryShells.some((shell) => shell !== undefined) ? mcpEntryRuntimePath() : undefined; + // The stdio lifecycle shell applies the operator `.env` layer (#469); the + // plain-Node module is inlined beside it. + const launchEnvRuntime = runtimeShell === undefined ? undefined : 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) @@ -436,6 +441,7 @@ export const planMcpEntriesSurface = async ( : { aliases: { [mcpEntryRuntimeSpecifier]: runtimeShell, + ...(launchEnvRuntime === undefined ? {} : { [launchEnvRuntimeSpecifier]: launchEnvRuntime }), ...(id !== eventHostId || eventIpcRuntime === undefined || eventProjectRuntime === undefined ? {} : { @@ -486,6 +492,7 @@ export const planMcpEntriesSurface = async ( : { ignoredSourcePaths: [ ...(runtimeShell === undefined ? [] : [runtimeShell]), + ...(launchEnvRuntime === undefined ? [] : [launchEnvRuntime]), ...(eventIpcRuntime === undefined ? [] : [runtimeIgnoredRoot(eventIpcRuntime)]), ...(serverRuntime === undefined ? [] : [runtimeIgnoredRoot(serverRuntime)]), ], @@ -560,6 +567,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 : { @@ -598,14 +606,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 @@ -614,11 +624,10 @@ export const planHooksSurface = ( })), ...(workerEntry === undefined ? [] : [workerEntry]), ], - ...(eventIpcRuntime === undefined - ? {} - : { - ignoredSourcePaths: [runtimeIgnoredRoot(eventIpcRuntime)], - }), + ignoredSourcePaths: [ + 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({ diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index e7f14e613..1981afbb7 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -2,6 +2,7 @@ import { existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { eventIpcRuntimeSpecifier, eventProjectRuntimeSpecifier } from '../adapters/hook-contract.ts'; +import { operatorEnvImports, operatorEnvStatement } from './launch-env-shell.ts'; import type { NoticeDeliveryAdvertisement } from '../adapters/notice-delivery.ts'; import { stableJson } from '../core/digest.ts'; import type { NormalizedHook, NormalizedNoticeRetentionPolicy, NormalizedStateDefinition } from '../core/types.ts'; @@ -52,6 +53,9 @@ export const mcpEntryRuntimePath = (): string => runtimeModulePath('mcp-entry'); export const mcpServerRuntimePath = (): string => runtimeModulePath('mcp-server-runtime'); +/** The on-disk `agent-bundle/launch-env` module (#469), aliased into every artifact shell that applies the operator `.env` layer. */ +export const launchEnvRuntimePath = (): string => runtimeModulePath('launch-env'); + /** * The terminal-capability probe (#511) aliased into `main`-envelope * executables: plain Node, dependency-free, so a plain script or bin learns @@ -67,14 +71,18 @@ export type GeneratedExecutableSurface = 'cli' | 'script'; /** * The generated stdio MCP entry body for a factory-exporting server module: * the lifecycle installs the console guard before the consumer module - * evaluates, so `loadEntry` stays a deferred dynamic import. + * evaluates, so `loadEntry` stays a deferred dynamic import — which is also + * what lets the operator `.env` layer land before any server code reads + * `process.env`. */ export const generatedStdioMcpEntrySource = (options: { readonly entrySource: string; readonly serverName: string; }): string => [ + ...operatorEnvImports({ importsFileUrlToPath: false }), `import { runGeneratedStdioMcpEntry } from ${JSON.stringify(mcpEntryRuntimeSpecifier)};`, '', + operatorEnvStatement, 'await runGeneratedStdioMcpEntry({', ` loadEntry: () => import(${JSON.stringify(options.entrySource)}),`, ` serverName: ${JSON.stringify(options.serverName)},`, @@ -343,9 +351,18 @@ export const generatedCliBinEntrySource = (options: GeneratedCliBinEntryOptions) ...pluginRootImports(stateFallback), ...(rendered ? ["import { Worker } from 'node:worker_threads';"] : []), ...generatedStateImports(options.state), + // The artifact-hosted executable is part of an installed pack, so it + // reads the pack's operator `.env` layer (#469); the npm package bin + // runs from the operator's own shell and reads none. The artifact + // plugin-root imports (#468) already bind `fileURLToPath`. + ...(stateFallback === 'artifact' ? operatorEnvImports({ importsFileUrlToPath: true }) : []), ...routeImports(commandRoutes), ...providerImports(providers), '', + // Before the state owner opens and before any command runs; route modules + // are static imports, so a module-level `process.env` read still sees the + // host environment only (documented). + ...(stateFallback === 'artifact' ? [operatorEnvStatement] : []), pluginRootDeclaration(stateFallback), ...generatedStateOwner(options.state, options), 'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };', diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index 0a9c967fd..f9396e9d0 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -9,6 +9,7 @@ import { generatedRouteFlightWorkerSource, generatedRouteMcpEntrySource, generatedStdioMcpEntrySource, + launchEnvRuntimePath, mcpEntryRuntimePath, mcpEntryRuntimeSpecifier, mcpServerRuntimePath, @@ -16,6 +17,7 @@ import { terminalCapabilityRuntimePath, terminalCapabilityRuntimeSpecifier, } from './entry-shell.ts'; +import { launchEnvRuntimeSpecifier } from './launch-env-shell.ts'; import { cliBinRslibEntries, planCompiledCliBins } from './cli-bins.ts'; import { planCompiledMcpEntries } from './entries.ts'; import { composeMcpAppsRsbuildConfig, planCompiledMcpApps } from './mcp-apps.ts'; @@ -226,6 +228,7 @@ const mcpEntryEntries = async ( ...(wrapped ? { aliases: { + [launchEnvRuntimeSpecifier]: launchEnvRuntimePath(), [mcpEntryRuntimeSpecifier]: mcpEntryRuntimePath(), ...(routeSource === undefined ? {} : { [mcpServerRuntimeSpecifier]: mcpServerRuntimePath() }), }, @@ -303,6 +306,7 @@ const hookEntries = ( const outputRoot = artifactOutputToken(target); return entries.map((entry) => rslibInspectionEntry({ entry: { + aliases: { [launchEnvRuntimeSpecifier]: launchEnvRuntimePath() }, name: entry.relativePath.replaceAll('/', '-').replace(/\.mjs$/u, ''), outputRelativePath: entry.relativePath, source: entry.hook.source, diff --git a/packages/agent-bundle/src/build/launch-env-shell.ts b/packages/agent-bundle/src/build/launch-env-shell.ts new file mode 100644 index 000000000..cfe3809a9 --- /dev/null +++ b/packages/agent-bundle/src/build/launch-env-shell.ts @@ -0,0 +1,34 @@ +/** + * The operator `.env` layer of an installed pack (#469): a plain-Node module + * aliased into every artifact shell that runs plugin code — the stdio MCP + * entry, the hook wrappers, the artifact CLI executable — so an installed + * pack reads `/.env` and `.env.local` (or `AGENT_BUNDLE_ENV_FILE`) + * at launch the way `agent-bundle mcp run` composes them: filling only the + * variables the host did not set, never logging a value. + */ +export const launchEnvRuntimeSpecifier = 'agent-bundle/launch-env'; + +// This module is imported by the hook contract, which the installer bundle +// (`install-entry`) also carries and a consumer's own Rspack run re-bundles, +// so it must stay free of filesystem probing and `new URL(…, import.meta.url)`: +// `launchEnvRuntimePath` lives in `entry-shell.ts` beside the other paths. + + +/** + * The import lines of the operator env layer. `fileURLToPath` is emitted + * only when the module does not already import it, so a bundle never + * declares one binding twice. + */ +export const operatorEnvImports = (options: { readonly importsFileUrlToPath: boolean }): readonly string[] => [ + ...(options.importsFileUrlToPath ? [] : ["import { fileURLToPath } from 'node:url';"]), + `import { applyOperatorEnv, operatorEnvPluginRoot } from ${JSON.stringify(launchEnvRuntimeSpecifier)};`, +]; + +/** + * The statement that applies the layer. Every artifact shell lives one + * directory below the plugin root (`mcp/`, `hooks/`, `bin/`), so the fallback + * anchor — used when the host set no `AGENT_BUNDLE_PLUGIN_ROOT` — is the + * module's parent directory, the same fallback the durable-state kernel uses. + */ +export const operatorEnvStatement = + "applyOperatorEnv({ pluginRoot: operatorEnvPluginRoot(fileURLToPath(new URL('..', import.meta.url))) });"; diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index 19de93a09..a561d3226 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -474,6 +474,16 @@ const humanDoctor = (result: DoctorReport): string => { ` durable state: ${stores} ${stores === 1 ? 'store' : 'stores'}, ${formatByteSize(bytes)}\n`, ); } + // The operator `.env` layer (#469): present files and their variable counts, never a value. + const operatorEnvFiles = [ + ...host.inventory.findings.map((finding) => finding.operatorEnv), + host.bundle?.operatorEnv, + ].flatMap((report) => report?.files ?? []).filter((file) => file.state !== 'absent'); + const uniqueEnvFiles = [...new Map(operatorEnvFiles.map((file) => [file.path, file])).values()]; + if (uniqueEnvFiles.length > 0) { + out.push(` operator env: ${uniqueEnvFiles.map((file) => + `${file.path} (${file.state === 'present' ? `${String(file.variables ?? 0)} variable${file.variables === 1 ? '' : 's'}` : file.state})`).join(', ')}\n`); + } } out.push( `runtime endpoints: ${result.endpoints.status}; ${result.endpoints.summary.live} live, ` + diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 267d4f30a..8d7c155e7 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -28,6 +28,7 @@ import type { } from '../host-contracts/process.ts'; import { runBoundedChildProcess } from '../host-contracts/process.ts'; import { requestEventRuntimeStatus } from '../events/ipc.ts'; +import { OPERATOR_ENV_FILE_NAMES, parseOperatorEnv } from '../launch-env.ts'; import { claudePluginRowErrors, parsePublicHostInventory, @@ -129,6 +130,8 @@ export interface DoctorFinding { /** Git commit of a staged Cursor marketplace repository. */ readonly commit?: string; readonly durableState?: DoctorDurableStateReport; + /** The operator `.env` layer the installed pack's shells read at launch (#469); names and counts only, never values. */ + readonly operatorEnv?: DoctorOperatorEnvReport; /** * Claude only: the row's `enabled` flag from `claude plugin list --json`. * `false` sets `state: 'disabled'` — the copy is installed but switched off, @@ -223,6 +226,20 @@ export interface DoctorDurableStateStore { readonly path: string; } +/** One operator env file of an installed pack: present (with its variable count) or absent. */ +export interface DoctorOperatorEnvFile { + readonly path: string; + readonly state: 'absent' | 'present' | 'unreadable'; + /** The number of variables the file declares; never their names or values. */ + readonly variables?: number; +} + +export interface DoctorOperatorEnvReport { + readonly diagnostics: readonly Diagnostic[]; + readonly files: readonly DoctorOperatorEnvFile[]; + readonly status: 'absent' | 'present' | 'warnings'; +} + export interface DoctorDurableStateReport { readonly diagnostics: readonly Diagnostic[]; readonly directory: string; @@ -655,6 +672,58 @@ const inspectDurableState = async ( return durableStateReport(directory, findings, diagnostics); }; +/** + * Whether an installed pack carries the operator `.env` layer its shells read + * at launch (#469). Doctor reports the files and how many variables each + * declares — the pack's credentials are configured, or not — and never a name + * or a value. Both files absent is the common case and produces no diagnostic. + */ +const inspectOperatorEnv = async ( + pluginRoot: string, + target?: DoctorHost, +): Promise => { + const files: DoctorOperatorEnvFile[] = []; + const diagnostics: Diagnostic[] = []; + for (const name of OPERATOR_ENV_FILE_NAMES) { + const path = join(pluginRoot, name); + let contents: string; + try { + contents = await readFile(path, 'utf8'); + } catch (error) { + if (isErrno(error, 'ENOENT')) { + files.push({ path, state: 'absent' }); + continue; + } + files.push({ path, state: 'unreadable' }); + diagnostics.push(diagnostic( + 'AB7331', + `Operator env file ${JSON.stringify(path)} exists but could not be read; the pack's shells skip it at launch.`, + 'Repair the file permissions so the installed pack can read its operator configuration, then rerun `agent-bundle doctor`.', + 'warning', + target, + )); + continue; + } + const variables = Object.keys(parseOperatorEnv(contents)).length; + files.push({ path, state: 'present', variables }); + diagnostics.push(diagnostic( + 'AB7331', + `Operator env file ${JSON.stringify(path)} is present and declares ${String(variables)} variable${variables === 1 ? '' : 's'}; ` + + 'the pack\'s MCP servers, hook wrappers, and CLI read it at launch to fill variables the host did not set.', + 'Nothing to do; remove the file to stop the pack from reading it. Doctor never reads variable names or values.', + 'info', + target, + )); + } + return Object.freeze({ + diagnostics: freezeDiagnostics(diagnostics), + files: Object.freeze(files), + status: diagnostics.some((entry) => entry.severity === 'warning') + ? 'warnings' + : files.some((file) => file.state === 'present') ? 'present' : 'absent', + }); +}; + const probeBinary = async ( host: Exclude, cwd: string, @@ -1015,6 +1084,8 @@ const cursorInventory = async ( if (launch !== undefined) diagnostics.push(...launch.diagnostics); const durableState = await inspectDurableState(path, 'cursor'); if (durableState !== undefined) diagnostics.push(...durableState.diagnostics); + const operatorEnv = await inspectOperatorEnv(path, 'cursor'); + diagnostics.push(...operatorEnv.diagnostics); const hooks = manifest.manifest === cursorManifestCandidates[0] ? await inspectCursorPluginHooks(path, home, { caseInsensitivePaths: platform === 'win32' }) : undefined; @@ -1033,6 +1104,7 @@ const cursorInventory = async ( ...(durableState === undefined ? {} : { durableState }), entry, ...(hooks === undefined ? {} : { hooks: hooks.registration }), + operatorEnv, ...(launch?.launch === undefined ? {} : { launch: launch.launch }), manifest: manifest.manifest, name: manifest.name, @@ -2591,12 +2663,15 @@ const doctorHost = async ( } const durableState = await inspectDurableState(identity.bundleRoot, host); if (durableState !== undefined) diagnostics.push(...durableState.diagnostics); + const operatorEnv = await inspectOperatorEnv(identity.bundleRoot, host); + diagnostics.push(...operatorEnv.diagnostics); bundle = Object.freeze({ ...checked.finding, ...(staticDiagnostics.some((entry) => entry.severity === 'error') ? { state: 'corrupt' as const } : {}), ...(durableState === undefined ? {} : { durableState }), + operatorEnv, }); } catch (error) { const malformed = malformedBundle(host, error); diff --git a/packages/agent-bundle/src/launch-env.ts b/packages/agent-bundle/src/launch-env.ts new file mode 100644 index 000000000..d514da0af --- /dev/null +++ b/packages/agent-bundle/src/launch-env.ts @@ -0,0 +1,178 @@ +/** + * The operator `.env` layer of an installed pack (#469): plain Node, no + * framework dependency, so every emitted stdio MCP entry, standalone hook + * wrapper, and artifact CLI executable can carry it without growing. + * + * `agent-bundle mcp run` already composes manifest env < `.env` files < + * `process.env` for the server it spawns. Hosts launch installed packs + * directly, so the emitted shells apply the same layer themselves: the file + * fills only variables the host did not set, so an exported variable always + * wins, and manifest env (already in `process.env` by the time a shell runs) + * loses to the file exactly as under `mcp run`. Values are never logged. + */ +import { readFileSync } from 'node:fs'; +import { delimiter, join, resolve } from 'node:path'; + +/** + * Names the file(s) to read instead of the plugin root's `.env` / `.env.local` + * pair: one path, or several joined by the platform path delimiter, applied + * in order (later files win). The value `none` disables the layer — which is + * what `agent-bundle mcp run --no-env` hands its child, so the shell's own + * layer never re-adds what the operator asked to leave out. + */ +export const OPERATOR_ENV_FILE_VARIABLE = 'AGENT_BUNDLE_ENV_FILE'; + +/** The `AGENT_BUNDLE_ENV_FILE` value that disables the operator env layer. */ +export const OPERATOR_ENV_FILE_NONE = 'none'; + +/** The conventional files, lowest priority first: `.env.local` overrides `.env`. */ +export const OPERATOR_ENV_FILE_NAMES: readonly string[] = Object.freeze(['.env', '.env.local']); + +export interface OperatorEnvFile { + readonly path: string; + /** `applied` counts the variables this file set; `absent` means the file does not exist. */ + readonly state: 'absent' | 'loaded' | 'unreadable'; + readonly applied?: number; +} + +export interface OperatorEnvResult { + /** The files considered, in the order they were applied. */ + readonly files: readonly OperatorEnvFile[]; + /** The variables the layer set, sorted; a name only, never a value. */ + readonly applied: readonly string[]; +} + +export interface OperatorEnvOptions { + /** The environment to fill; `process.env` by default. Mutated in place. */ + readonly env?: NodeJS.ProcessEnv; + /** + * The plugin root the files live under: the expanded `AGENT_BUNDLE_PLUGIN_ROOT` + * when the host set one, otherwise the shell's own fallback (the artifact root + * — the parent of `mcp/`, `bin/`, `hooks/` — or the caller's `.agent-bundle`). + */ + readonly pluginRoot: string; +} + +const unexpandedToken = /\$\{[^}]*\}/u; + +/** + * The plugin root an emitted shell reads its operator files from: the same + * precedence the runtime's `resolvePluginRoot` applies (an expanded + * `AGENT_BUNDLE_PLUGIN_ROOT`, else the shell's fallback), spelled here without + * a runtime import so the shared-runtime hook wrapper stays dependency-free. + */ +export const operatorEnvPluginRoot = ( + fallback: string, + env: Readonly> = process.env, +): string => { + const declared = env['AGENT_BUNDLE_PLUGIN_ROOT']?.trim() ?? ''; + return declared === '' || unexpandedToken.test(declared) ? resolve(fallback) : resolve(declared); +}; + +/** + * The files a launch considers, in application order: the explicit + * `AGENT_BUNDLE_ENV_FILE` list, nothing for `none`, or the plugin root's + * conventional pair. + */ +export const operatorEnvFilePaths = ( + pluginRoot: string, + env: Readonly> = process.env, +): readonly string[] => { + const explicit = env[OPERATOR_ENV_FILE_VARIABLE]?.trim() ?? ''; + if (explicit === OPERATOR_ENV_FILE_NONE) return Object.freeze([]); + if (explicit !== '') { + return Object.freeze(explicit.split(delimiter).map((path) => path.trim()).filter((path) => path !== '').map((path) => resolve(path))); + } + return Object.freeze(OPERATOR_ENV_FILE_NAMES.map((name) => join(pluginRoot, name))); +}; + +const unquote = (raw: string): string => { + const value = raw.trim(); + if (value.length >= 2) { + const quote = value[0]; + if ((quote === '"' || quote === "'" || quote === '`') && value.endsWith(quote)) { + const inner = value.slice(1, -1); + return quote === '"' ? inner.replace(/\\n/gu, '\n').replace(/\\r/gu, '\r').replace(/\\"/gu, '"') : inner; + } + } + // An unquoted value ends at the first ` #` comment. + const comment = value.search(/\s#/u); + return (comment === -1 ? value : value.slice(0, comment)).trim(); +}; + +/** + * The dotenv grammar the shells accept: `KEY=value` lines, an optional + * `export ` prefix, blank lines and `#` comments, single-, double-, or + * backtick-quoted values (double quotes expand `\n`, `\r`, `\"`), and a + * multi-line double- or single-quoted value that closes on a later line. No + * `${VAR}` interpolation — a value is used exactly as written. Later lines + * win over earlier ones for the same key. + */ +export const parseOperatorEnv = (contents: string): Record => { + const parsed: Record = {}; + const lines = contents.replace(/\r\n?/gu, '\n').split('\n'); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]!.trim(); + if (line === '' || line.startsWith('#')) continue; + const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/u.exec(line); + if (match === null) continue; + const key = match[1]!; + let raw = match[2]!.trim(); + const quote = raw[0]; + if (quote === '"' || quote === "'") { + // A quoted value continues until a line ends with the closing quote. + const closed = (): boolean => raw.length >= 2 && raw.endsWith(quote) && !raw.endsWith(`\\${quote}`); + while (!closed() && index + 1 < lines.length) { + index += 1; + raw += `\n${lines[index]!}`; + } + } + parsed[key] = unquote(raw); + } + return parsed; +}; + +const readOptional = (path: string): string | undefined | null => { + try { + return readFileSync(path, 'utf8'); + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ENOENT' ? undefined : null; + } +}; + +/** + * Applies the operator `.env` layer to `env` in place, filling only variables + * the host did not set, and reports what happened without ever touching a + * value. Missing files are the normal case (most packs need none); an + * unreadable one is reported and skipped, never fatal — a pack must start + * even when its operator file has the wrong permissions. + */ +export const applyOperatorEnv = (options: OperatorEnvOptions): OperatorEnvResult => { + const env = options.env ?? process.env; + const reserved = new Set(Object.keys(env).filter((key) => env[key] !== undefined)); + const files: OperatorEnvFile[] = []; + const applied = new Set(); + for (const path of operatorEnvFilePaths(options.pluginRoot, env)) { + const contents = readOptional(path); + if (contents === undefined) { + files.push({ path, state: 'absent' }); + continue; + } + if (contents === null) { + files.push({ path, state: 'unreadable' }); + continue; + } + let count = 0; + for (const [key, value] of Object.entries(parseOperatorEnv(contents))) { + if (reserved.has(key)) continue; + env[key] = value; + applied.add(key); + count += 1; + } + files.push({ applied: count, path, state: 'loaded' }); + } + return Object.freeze({ + applied: Object.freeze([...applied].sort((left, right) => left.localeCompare(right))), + files: Object.freeze(files), + }); +}; diff --git a/packages/agent-bundle/src/services/mcp-run.ts b/packages/agent-bundle/src/services/mcp-run.ts index 55b8900d3..2fe591227 100644 --- a/packages/agent-bundle/src/services/mcp-run.ts +++ b/packages/agent-bundle/src/services/mcp-run.ts @@ -1,6 +1,6 @@ import { loadEnv } from '@rsbuild/core'; import { spawn, type ChildProcess } from 'node:child_process'; -import { resolve } from 'node:path'; +import { delimiter, resolve } from 'node:path'; import { parseEnv } from 'node:util'; import { Effect, FileSystem } from 'effect'; @@ -14,6 +14,7 @@ import { parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; import { runPromise } from '../effect/boundary.ts'; import { liftPromise } from '../effect/lift.ts'; import { readFileString, runWithPlatform } from '../effect/platform.ts'; +import { OPERATOR_ENV_FILE_NONE, OPERATOR_ENV_FILE_VARIABLE } from '../launch-env.ts'; import { resolveMcpPathTokens } from './mcp-path-tokens.ts'; import { forwardingSignals } from './mcp-run-signals.ts'; import { @@ -202,12 +203,33 @@ const loadLaunchFileEnv = async ( }).parsed; }; +/** + * What the spawned entry's own operator `.env` layer (#469) should do. The + * generated shell reads `/.env` and `.env.local` at launch, and + * under `mcp run` the plugin root is the workspace root, so by default it + * re-reads the same files this process already composed — a no-op, because + * the layer fills only missing variables. `--env-file` and `--no-env` name a + * different set, so they are handed down as `AGENT_BUNDLE_ENV_FILE` and the + * shell follows the operator's choice instead of the convention. An operator + * who exported `AGENT_BUNDLE_ENV_FILE` keeps it: `process.env` wins anyway. + */ +const operatorEnvFileForChild = ( + options: McpLaunchEnvironmentOptions, +): Readonly> => { + if (options.loadEnvFiles === false) return { [OPERATOR_ENV_FILE_VARIABLE]: OPERATOR_ENV_FILE_NONE }; + if (options.envFiles !== undefined && options.envFiles.length > 0) { + return { [OPERATOR_ENV_FILE_VARIABLE]: options.envFiles.map((file) => resolve(file)).join(delimiter) }; + } + return {}; +}; + /** * The complete launch of one stdio server out of a built artifact: the * resolved command plus the layered environment `mcp run` and `serve-app` * share. Precedence, lowest to highest: manifest env (declared entries plus * the injected plugin-root anchor, path tokens expanded), the `.env` file - * layer, then the operator's real `process.env` — an exported variable + * layer, the `AGENT_BUNDLE_ENV_FILE` hand-down for the child's own operator + * layer (#469), then the operator's real `process.env` — an exported variable * always beats every file- or manifest-declared value. The plugin-data root * is created so the server's durable-state anchor exists before it starts. */ @@ -227,7 +249,7 @@ export const resolveMcpLaunchEnvironment = async ( args: launch.args, command: launch.command, cwd: launch.cwd, - env: Object.freeze({ ...launch.env, ...fileEnv, ...inheritedEnv }), + env: Object.freeze({ ...launch.env, ...fileEnv, ...operatorEnvFileForChild(options), ...inheritedEnv }), }); }; diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 7844787a9..5f535749c 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -557,6 +557,73 @@ it('inventories durable SQLite stores and sidecars without opening them', async } }); +it('reports whether an installed pack carries an operator .env file, never its contents (#469)', async () => { + const fixture = await temporaryDoctor(); + const pluginRoot = join(fixture.home, '.cursor', 'plugins', 'local', 'configured'); + try { + await writeJson( + join(pluginRoot, '.cursor-plugin/plugin.json'), + { name: 'configured', version: '1.0.0' }, + ); + await writeFile(join(pluginRoot, '.env'), '# operator credentials\nIPT_SESSION=s3cr3t-session\nMAM_SESSION="s3cr3t-mam"\n'); + + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: ['cursor'], + }); + const finding = hostReport(report, 'cursor').inventory.findings.find((entry) => entry.entry === 'configured'); + expect(finding?.operatorEnv).toEqual({ + diagnostics: [expect.objectContaining({ code: 'AB7331', severity: 'info' })], + files: [ + { path: join(pluginRoot, '.env'), state: 'present', variables: 2 }, + { path: join(pluginRoot, '.env.local'), state: 'absent' }, + ], + status: 'present', + }); + const info = report.diagnostics.find((entry) => entry.code === 'AB7331'); + expect(info?.message).toContain(`Operator env file ${JSON.stringify(join(pluginRoot, '.env'))} is present and declares 2 variables`); + // Names and values stay out of the report entirely. + expect(JSON.stringify(report)).not.toMatch(/s3cr3t|IPT_SESSION|MAM_SESSION/u); + expect(report.summary).toMatchObject({ errors: 0, warnings: 0 }); + + const human = captureCliTerminal(); + await runCli(['doctor'], human.output, { runDoctor: async () => report }); + expect(human.stdout()).toContain(`operator env: ${join(pluginRoot, '.env')} (2 variables)`); + expect(human.stdout()).not.toContain('s3cr3t'); + } finally { + await fixture.cleanup(); + } +}); + +it('records both operator env files as absent for a pack that ships none, without a diagnostic', async () => { + const fixture = await temporaryDoctor(); + const pluginRoot = join(fixture.home, '.cursor', 'plugins', 'local', 'plain'); + try { + await writeJson(join(pluginRoot, '.cursor-plugin/plugin.json'), { name: 'plain', version: '1.0.0' }); + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: ['cursor'], + }); + const finding = hostReport(report, 'cursor').inventory.findings.find((entry) => entry.entry === 'plain'); + expect(finding?.operatorEnv).toEqual({ + diagnostics: [], + files: [ + { path: join(pluginRoot, '.env'), state: 'absent' }, + { path: join(pluginRoot, '.env.local'), state: 'absent' }, + ], + status: 'absent', + }); + expect(report.diagnostics.some((entry) => entry.code === 'AB7331')).toBe(false); + const human = captureCliTerminal(); + await runCli(['doctor'], human.output, { runDoctor: async () => report }); + expect(human.stdout()).not.toContain('operator env:'); + } finally { + await fixture.cleanup(); + } +}); + it('inventories durable state under a checked --from bundle', async () => { const fixture = await temporaryDoctor(); try { diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index a2f1e43d8..60c6f4bd6 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -93,6 +93,55 @@ describe('generated entry templates', () => { // The consumer module must never be statically imported: the console // guard has to activate before its side effects can reach stdout. expect(source).not.toMatch(/^import[^\n]*curator\.ts/mu); + // The operator `.env` layer (#469) lands before the deferred import, so + // server code reads a composed process.env; the anchor is the artifact + // root (the parent of `mcp/`) unless the host set AGENT_BUNDLE_PLUGIN_ROOT. + expect(source).toContain("import { applyOperatorEnv, operatorEnvPluginRoot } from \"agent-bundle/launch-env\";"); + expect(source.indexOf("applyOperatorEnv({ pluginRoot: operatorEnvPluginRoot(fileURLToPath(new URL('..', import.meta.url))) });")) + .toBeLessThan(source.indexOf('await runGeneratedStdioMcpEntry(')); + }); + + it('applies the operator .env layer in every artifact shell that runs plugin code, and only there (#469)', () => { + const route = { + config: {}, + id: 'cli:report', + kind: 'cli' as const, + provenance: { kind: 'conventional' as const, relativePath: 'src/cli/report.ts' }, + source: '/project/src/cli/report.ts', + }; + const command = { aliases: [], exitCode: 'zero' as const, options: [], path: ['report'], rendered: false, routeId: 'cli:report' }; + const artifactBin = entryShellModule.generatedCliBinEntrySource({ + commands: [command], + plugin: { name: 'fixture', version: '1.0.0' }, + routes: [route], + stateFallback: 'artifact', + }); + expect(artifactBin).toContain("from \"agent-bundle/launch-env\""); + expect(artifactBin).toContain("import { fileURLToPath } from 'node:url';"); + expect(artifactBin.indexOf("applyOperatorEnv({ pluginRoot: operatorEnvPluginRoot(fileURLToPath(new URL('..', import.meta.url))) });")) + .toBeLessThan(artifactBin.indexOf('const processLifetime')); + // With durable state the bin already imports fileURLToPath; the layer must not declare it twice. + const durableBin = entryShellModule.generatedCliBinEntrySource({ + commands: [command], + plugin: { name: 'fixture', version: '1.0.0' }, + routes: [route], + state: { + id: 'project/tasks', + lifetime: 'workspace-durable', + provenance: { kind: 'conventional', sourcePath: '/project/src/state.ts' }, + source: '/project/src/state.ts', + }, + stateFallback: 'artifact', + }); + expect(durableBin.match(/import \{ fileURLToPath \} from 'node:url';/gu)).toHaveLength(1); + // The npm package bin runs from the operator's own shell and reads no pack file. + const npmBin = entryShellModule.generatedCliBinEntrySource({ + commands: [command], + plugin: { name: 'fixture', version: '1.0.0' }, + routes: [route], + }); + expect(npmBin).not.toContain('agent-bundle/launch-env'); + expect(npmBin).not.toContain('applyOperatorEnv'); }); it('generates a process envelope that adopts numeric exit codes and hands main the terminal capability (#511)', () => { diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index 260edf603..16cb16dda 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -1051,6 +1051,66 @@ it('compiles each native hook through a virtual Rslib entry without sibling chun } }); +it('applies the operator .env layer of the installed pack before a hook handler runs, filling only what the host did not set (#469)', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-hooks-operator-env-')); + const sourceRoot = join(root, 'src', 'hooks'); + const outputRoot = join(root, 'dist'); + const model = hookModel(root); + + try { + await mkdir(sourceRoot, { recursive: true }); + await Promise.all([ + writeFile(join(root, 'agent-bundle.config.ts'), 'export default {};\n'), + writeFile(join(root, 'package.json'), '{"type":"module"}\n'), + writeFile(join(sourceRoot, 'session-start.ts'), [ + "export default () => ({ outcome: 'continue' as const, additionalContext: `${process.env.OPERATOR_TOKEN ?? 'unset'}:${process.env.HOST_WINS ?? 'unset'}` });", + '', + ].join('\n')), + writeFile(join(sourceRoot, 'check-command.ts'), "export default () => ({ outcome: 'continue' as const });\n"), + writeFile(join(sourceRoot, 'record.ts'), "export default () => ({ outcome: 'continue' as const });\n"), + writeFile(join(sourceRoot, 'stop.ts'), "export default () => ({ outcome: 'continue' as const });\n"), + ]); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + + const event = { + cwd: '/workspace', hook_event_name: 'SessionStart', session_id: 'session-1', source: 'startup', transcript_path: '/workspace/transcript.json', + }; + const context = (result: { readonly stdout: string }): string => + (JSON.parse(result.stdout) as { hookSpecificOutput: { additionalContext: string } }).hookSpecificOutput.additionalContext; + for (const target of ['codex', 'claude']) { + const pluginRoot = join(outputRoot, target); + const wrapper = join(pluginRoot, 'hooks', 'session-start-session-start-7ab7e8a5.mjs'); + // No file: the wrapper is a no-op and the handler sees the host environment only. + const withoutFile = await runNativeHook(wrapper, event); + expect(withoutFile).toMatchObject({ code: 0, stderr: '' }); + expect(context(withoutFile)).toBe('unset:unset'); + + // `/.env` fills the gap; an exported variable still wins. + await writeFile(join(pluginRoot, '.env'), 'OPERATOR_TOKEN=from-file\nHOST_WINS=from-file\n'); + const withFile = await runNodeScript({ args: [wrapper], env: { HOST_WINS: 'host' }, input: JSON.stringify(event) }); + expect(withFile).toMatchObject({ code: 0, stderr: '' }); + expect(context(withFile)).toBe('from-file:host'); + + // The anchor the host injects decides the location: pointed elsewhere, the artifact's file is not read. + const elsewhere = await mkdtemp(join(tmpdir(), 'agent-bundle-hooks-operator-env-anchor-')); + try { + await writeFile(join(elsewhere, '.env'), 'OPERATOR_TOKEN=from-anchor\n'); + const anchored = await runNodeScript({ args: [wrapper], env: { AGENT_BUNDLE_PLUGIN_ROOT: elsewhere }, input: JSON.stringify(event) }); + expect(context(anchored)).toBe('from-anchor:unset'); + // An explicit AGENT_BUNDLE_ENV_FILE replaces the convention; `none` disables the layer. + const explicit = await runNodeScript({ args: [wrapper], env: { AGENT_BUNDLE_ENV_FILE: join(elsewhere, '.env') }, input: JSON.stringify(event) }); + expect(context(explicit)).toBe('from-anchor:unset'); + const disabled = await runNodeScript({ args: [wrapper], env: { AGENT_BUNDLE_ENV_FILE: 'none' }, input: JSON.stringify(event) }); + expect(context(disabled)).toBe('unset:unset'); + } finally { + await rm(elsewhere, { force: true, recursive: true }); + } + } + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('runs the embedded Codex and Claude native codecs through their published wrappers', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-hooks-native-codecs-')); const sourceRoot = join(root, 'src', 'hooks'); diff --git a/packages/agent-bundle/tests/launch-env.test.ts b/packages/agent-bundle/tests/launch-env.test.ts new file mode 100644 index 000000000..3b3c52816 --- /dev/null +++ b/packages/agent-bundle/tests/launch-env.test.ts @@ -0,0 +1,143 @@ +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { delimiter, join, resolve } from 'node:path'; + +import { afterEach, describe, expect, it } from '@rstest/core'; + +import { + OPERATOR_ENV_FILE_VARIABLE, + applyOperatorEnv, + operatorEnvFilePaths, + operatorEnvPluginRoot, + parseOperatorEnv, +} from '../src/launch-env.ts'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const createRoot = async (): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-launch-env-')); + roots.push(root); + return root; +}; + +describe('the operator .env layer of an installed pack (#469)', () => { + it('parses the dotenv grammar the shells accept, without interpolation', () => { + expect(parseOperatorEnv([ + '# a comment', + '', + 'PLAIN=value', + 'export EXPORTED=yes', + 'SPACED = padded ', + 'DOUBLE="two\\nlines \\"quoted\\""', + "SINGLE='keep \\n literally'", + 'BACKTICK=`ticks`', + 'INLINE=value # trailing comment', + 'HASH_IN_QUOTES="a # b"', + 'LITERAL=${NOT_EXPANDED}', + 'MULTI="first', + 'second"', + 'REPEATED=first', + 'REPEATED=second', + 'not a pair', + '9BAD=ignored', + ].join('\n'))).toEqual({ + BACKTICK: 'ticks', + DOUBLE: 'two\nlines "quoted"', + EXPORTED: 'yes', + HASH_IN_QUOTES: 'a # b', + INLINE: 'value', + LITERAL: '${NOT_EXPANDED}', + MULTI: 'first\nsecond', + PLAIN: 'value', + REPEATED: 'second', + SINGLE: 'keep \\n literally', + SPACED: 'padded', + }); + }); + + it('resolves the plugin root with the anchor precedence the runtime uses', () => { + expect(operatorEnvPluginRoot('/artifact/claude', {})).toBe('/artifact/claude'); + expect(operatorEnvPluginRoot('/artifact/claude', { AGENT_BUNDLE_PLUGIN_ROOT: '/installs/curator' })).toBe('/installs/curator'); + expect(operatorEnvPluginRoot('/artifact/claude', { AGENT_BUNDLE_PLUGIN_ROOT: ' ' })).toBe('/artifact/claude'); + // An unexpanded host token is treated as unset, never joined into a path. + expect(operatorEnvPluginRoot('/artifact/claude', { AGENT_BUNDLE_PLUGIN_ROOT: '${CLAUDE_PLUGIN_ROOT}' })).toBe('/artifact/claude'); + expect(operatorEnvPluginRoot('/artifact/claude', { AGENT_BUNDLE_PLUGIN_ROOT: './' })).toBe(resolve('./')); + }); + + it('considers the conventional pair, an explicit list, or nothing for "none"', () => { + expect(operatorEnvFilePaths('/installs/curator', {})).toEqual(['/installs/curator/.env', '/installs/curator/.env.local']); + expect(operatorEnvFilePaths('/installs/curator', { [OPERATOR_ENV_FILE_VARIABLE]: `/etc/curator.env${delimiter}./local.env` })) + .toEqual(['/etc/curator.env', resolve('./local.env')]); + expect(operatorEnvFilePaths('/installs/curator', { [OPERATOR_ENV_FILE_VARIABLE]: 'none' })).toEqual([]); + }); + + it('fills only the variables the host did not set, .env.local over .env, and never mutates a host value', async () => { + const root = await createRoot(); + await writeFile(join(root, '.env'), 'FROM_FILE=s3cr3t-token\nHOST_WINS=s3cr3t-token\nLOCAL_WINS=base\n'); + await writeFile(join(root, '.env.local'), 'LOCAL_WINS=local\nLOCAL_ONLY=1\n'); + const env: NodeJS.ProcessEnv = { HOST_WINS: 'host', UNRELATED: 'kept' }; + + const result = applyOperatorEnv({ env, pluginRoot: root }); + + expect(env).toEqual({ FROM_FILE: 's3cr3t-token', HOST_WINS: 'host', LOCAL_ONLY: '1', LOCAL_WINS: 'local', UNRELATED: 'kept' }); + expect(result).toEqual({ + applied: ['FROM_FILE', 'LOCAL_ONLY', 'LOCAL_WINS'], + files: [ + { applied: 2, path: join(root, '.env'), state: 'loaded' }, + { applied: 2, path: join(root, '.env.local'), state: 'loaded' }, + ], + }); + // Nothing about a value is in the report. + expect(JSON.stringify(result)).not.toContain('s3cr3t'); + }); + + it('is a no-op with absent files and reports them as absent', async () => { + const root = await createRoot(); + const env: NodeJS.ProcessEnv = { KEEP: '1' }; + + expect(applyOperatorEnv({ env, pluginRoot: root })).toEqual({ + applied: [], + files: [ + { path: join(root, '.env'), state: 'absent' }, + { path: join(root, '.env.local'), state: 'absent' }, + ], + }); + expect(env).toEqual({ KEEP: '1' }); + }); + + it('honours AGENT_BUNDLE_ENV_FILE from the environment it fills, including "none"', async () => { + const root = await createRoot(); + await writeFile(join(root, '.env'), 'CONVENTIONAL=1\n'); + await mkdir(join(root, 'etc')); + await writeFile(join(root, 'etc', 'a.env'), 'A=1\nSHARED=a\n'); + await writeFile(join(root, 'etc', 'b.env'), 'B=1\nSHARED=b\n'); + + const explicit: NodeJS.ProcessEnv = { [OPERATOR_ENV_FILE_VARIABLE]: [join(root, 'etc', 'a.env'), join(root, 'etc', 'b.env')].join(delimiter) }; + expect(applyOperatorEnv({ env: explicit, pluginRoot: root }).applied).toEqual(['A', 'B', 'SHARED']); + expect(explicit).toMatchObject({ A: '1', B: '1', SHARED: 'b' }); + expect(explicit).not.toHaveProperty('CONVENTIONAL'); + + const disabled: NodeJS.ProcessEnv = { [OPERATOR_ENV_FILE_VARIABLE]: 'none' }; + expect(applyOperatorEnv({ env: disabled, pluginRoot: root })).toEqual({ applied: [], files: [] }); + expect(disabled).not.toHaveProperty('CONVENTIONAL'); + }); + + it('skips an unreadable file without failing the launch', async () => { + if (process.getuid?.() === 0) return; // root reads anything + const root = await createRoot(); + await writeFile(join(root, '.env'), 'SECRET=1\n'); + await chmod(join(root, '.env'), 0o000); + const env: NodeJS.ProcessEnv = {}; + try { + const result = applyOperatorEnv({ env, pluginRoot: root }); + expect(result.files[0]).toEqual({ path: join(root, '.env'), state: 'unreadable' }); + expect(env).toEqual({}); + } finally { + await chmod(join(root, '.env'), 0o600); + } + }); +}); diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index 90ce6847f..2e7067c25 100644 --- a/packages/agent-bundle/tests/package-build.test.ts +++ b/packages/agent-bundle/tests/package-build.test.ts @@ -409,9 +409,15 @@ describe('mcp run', () => { const custom = await captureLaunch({ ...base, envFiles: [join(root, 'custom.env')] }); expect(custom.env.CUSTOM_ONLY).toBe('custom'); expect(custom.env.FROM_DOTENV).toBeUndefined(); + // The spawned shell applies its own operator `.env` layer at launch + // (#469); the operator's explicit choice rides down as AGENT_BUNDLE_ENV_FILE + // so the shell follows it instead of re-reading the conventional pair. + expect(custom.env.AGENT_BUNDLE_ENV_FILE).toBe(join(root, 'custom.env')); + expect(bare.env.AGENT_BUNDLE_ENV_FILE).toBeUndefined(); const disabled = await captureLaunch({ ...base, loadEnvFiles: false }); expect(disabled.env.FROM_DOTENV).toBeUndefined(); expect(disabled.env.AGENT_BUNDLE_PLUGIN_ROOT).toBe(root); + expect(disabled.env.AGENT_BUNDLE_ENV_FILE).toBe('none'); // pluginRoot restores the byte-faithful artifact-rooted rehearsal. const rehearsal = await captureLaunch({ ...base, pluginRoot: join(artifact, 'portable') }); diff --git a/packages/agent-bundle/tests/packed-stdio-projection.test.ts b/packages/agent-bundle/tests/packed-stdio-projection.test.ts index 2e0984f49..7f874a6c4 100644 --- a/packages/agent-bundle/tests/packed-stdio-projection.test.ts +++ b/packages/agent-bundle/tests/packed-stdio-projection.test.ts @@ -1,5 +1,5 @@ import { execFile as executeFile } from 'node:child_process'; -import { cp, mkdtemp, readdir, readFile, rm } from 'node:fs/promises'; +import { cp, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { promisify } from 'node:util'; @@ -54,6 +54,27 @@ it('serves compiled routes and durable state across packed process restarts', as try { await cp(fixtureRoot, project, { recursive: true }); + // The operator `.env` probe (#469) lives in this packed copy only: the + // shared fixture's lists stay untouched, and the packed level is the one + // that spawns the real entry whose shell applies the layer. + await writeFile(join(project, 'src', 'mcp', 'harness', 'tools', 'env-probe.tsx'), [ + "import { Agent } from '@agent-bundle/runtime';", + "import { z } from 'zod';", + '', + "export const config = { annotations: { readOnlyHint: true }, description: 'Reports one environment variable as the server process sees it.' };", + 'export const inputSchema = z.object({ name: z.string().min(1) }).strict();', + 'export const resultSchema = z.object({ name: z.string(), value: z.string().nullable() }).strict();', + '', + 'export default async function EnvProbe({ input }: { readonly input: z.infer }) {', + ' const value = process.env[input.name] ?? null;', + ' return (', + ' ', + ' {`${input.name}: ${value ?? "(unset)"}`}', + ' ', + ' );', + '}', + '', + ].join('\n')); await execFile('npm', ['install', ...cachedNpmInstallArguments, agentBundle.tarball, runtime.tarball, @@ -87,7 +108,13 @@ it('serves compiled routes and durable state across packed process restarts', as const env = { ...installedEnvironment(), ...serverEnvironment, + // The host exported this one; the operator file must not override it. + HARNESS_HOST_WINS: 'from-host', } as Record; + // The operator configuration of an installed pack (#469): a file the + // receipt never owns, beside the manifest, read by the shell at launch. + await writeFile(join(pluginRoot, '.env'), 'HARNESS_FROM_FILE=s3cr3t-from-file\nHARNESS_HOST_WINS=from-file\n'); + await writeFile(join(pluginRoot, '.env.local'), 'HARNESS_LOCAL=from-local\n'); const worker = entry.replace(/\.mjs$/u, '-flight.mjs'); const workerSource = await readFile(worker, 'utf8'); expect(workerSource).toContain('node:sqlite'); @@ -164,6 +191,7 @@ it('serves compiled routes and durable state across packed process restarts', as 'catalog', 'context', 'echo', + 'env-probe', 'fault', 'journal', 'layout-probe', @@ -217,6 +245,19 @@ it('serves compiled routes and durable state across packed process restarts', as }, }, }); + // The pack's operator `.env` layer reached the server process: the file + // fills `HARNESS_FROM_FILE` and `.env.local`'s `HARNESS_LOCAL`, the host's + // exported `HARNESS_HOST_WINS` is untouched, and nothing was logged. + for (const [name, value] of [ + ['HARNESS_FROM_FILE', 's3cr3t-from-file'], + ['HARNESS_LOCAL', 'from-local'], + ['HARNESS_HOST_WINS', 'from-host'], + ['HARNESS_ABSENT', null], + ] as const) { + await expect(firstSession.client.callTool({ arguments: { name }, name: 'env-probe' })) + .resolves.toMatchObject({ structuredContent: { name, value } }); + } + expect(firstSession.stderr()).not.toContain('s3cr3t'); await expect(firstSession.client.callTool({ arguments: { genre: 'mystery' }, name: 'catalog' })) .resolves.toMatchObject({ content: [ @@ -264,7 +305,10 @@ it('serves compiled routes and durable state across packed process restarts', as }); const matrixReport = await runPackedContractMatrix({ eventRuntime: { endpointId: eventRuntimeEndpointId }, - fixtures: routeHarnessPackedContractFixtures(), + fixtures: { + ...routeHarnessPackedContractFixtures(), + 'tool:harness/env-probe': { input: { name: 'HARNESS_FROM_FILE' }, resultCompat: 'closed' }, + }, manifest: harnessManifest, restart: async () => { await firstSession.close(); diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index 4a97ab62f..177b3c993 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -347,9 +347,12 @@ Self-connecting entries — modules that construct and connect a transport at to default export — keep their existing behavior byte for byte. Source validation reports the informational `AB4730` nudge suggesting the factory upgrade; it is never an error. -The same lifecycle is public API for hand-rolled entries: +The same lifecycle is public API for hand-rolled entries, and so is the operator `.env` layer the +generated shell applies before it imports the server module (see +[Installation](../distribution/installation.mdx#operator-configuration-the-installed-packs-env)): ```ts +import { applyOperatorEnv } from 'agent-bundle/launch-env'; import { redirectConsoleToStderr, runStdioServer } from 'agent-bundle/mcp-entry'; ``` diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index eb2ccc05c..bbff4ca52 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -98,6 +98,49 @@ a pre-receipt copy. Claude replacement runs `claude plugin uninstall --keep-data reinstalling because `plugin update` is version-gated; Codex runs `codex plugin remove` before `add`. The emitted `INSTALL.md` documents the same recipe per host. +## Operator configuration: the installed pack's `.env` + +A plugin configured through environment variables — session cookies, API tokens, a host name — is +configured at the install, not in the artifact: manifest `env` is compiled, content-addressed, and +re-emitted on every build, which is the wrong place for a secret. Instead, put a `.env` file at the +plugin root of the installed copy (the directory that holds `.claude-plugin/`, `.cursor-plugin/`, +or the Codex plugin manifest, beside `mcp/`, `hooks/`, and `bin/`), optionally with a `.env.local` +beside it: + +```sh +# ~/.cursor/plugins/local/movie-library/.env +IPT_SESSION=… +RTORRENT_SSH_HOST=nas.local +``` + +Every shell the pack emits reads that file when it starts — the stdio MCP server entries, the +hook wrappers that run plugin code, and the artifact CLI `bin/.mjs` — under one rule: + +| Precedence | Layer | Contents | +| --- | --- | --- | +| 3 (highest) | Host environment | What the host exported and what its manifest `env` declared (including `AGENT_BUNDLE_PLUGIN_ROOT`). An exported variable always wins. | +| 2 | `.env.local` | Fills gaps only; beats `.env` for the same key. | +| 1 (lowest) | `.env` | Fills gaps only. | + +The location is the plugin root the pack resolved — `AGENT_BUNDLE_PLUGIN_ROOT` when the host set +it, otherwise the directory above the shell's own (`mcp/`, `hooks/`, `bin/`). `AGENT_BUNDLE_ENV_FILE` +names one or more files (platform path delimiter) to read instead, and `AGENT_BUNDLE_ENV_FILE=none` +disables the layer. The grammar is dotenv's: `KEY=value`, `export KEY=value`, `#` comments, single-, +double-, or backtick-quoted values (double quotes expand `\n`), multi-line quoted values, and no +`${VAR}` interpolation. A missing file is the normal case and costs nothing; an unreadable one is +skipped so the pack still starts. Values are never logged. The file is not owned by the install +receipt, so `install --replace` and a same-version rebuild leave it in place, and `uninstall` never +removes unowned entries: the file survives an uninstall too (Doctor then reports the directory as a +remnant, `AB7307`), so delete it by hand when the credentials should go. Route and provider code sees the composed +environment when it runs; a hook handler or CLI route module that reads `process.env` at import +time still sees the host environment only, because those modules are static imports of their +wrapper — MCP server modules load after the layer and see it everywhere. `agent-bundle mcp run` +composes the same three layers itself and hands `--env-file` / `--no-env` down as +`AGENT_BUNDLE_ENV_FILE`, so a rehearsal and an installed pack read the same files. + +`agent-bundle doctor` reports whether an installed copy carries `.env` or `.env.local` and how many +variables each declares (`AB7331`, informational) — never a name or a value. + ## Uninstalling by receipt ```sh @@ -135,7 +178,7 @@ npx agent-bundle doctor --from artifact --json Doctor is read-only. It probes hosts, inventories installed bundles, compares them against a supplied bundle, checks registration proof, samples runtime endpoint health and identity, -inventories durable state, and re-runs the pinned process-free document and loader validators over +inventories durable state and the operator `.env` layer (`AB7331`), and re-runs the pinned process-free document and loader validators over installed bytes. It never repairs anything. With `--from`, it reports the installed copy per host as `current`, `stale` (`AB7308`), `version-mismatch` (`AB7309`), `foreign` (`AB7321`), `load-failed` (`AB7325`), or `not-installed` (`AB7307`), and its lifecycle stage — placed → registered → enabled → active — with diff --git a/website/docs/en/reference/api.mdx b/website/docs/en/reference/api.mdx index 67b585bee..53e224efc 100644 --- a/website/docs/en/reference/api.mdx +++ b/website/docs/en/reference/api.mdx @@ -24,6 +24,7 @@ Every public entry point is documented from its declarations: | `agent-bundle/mcp-apps` | The MCP App resource registry contract, replaced by the compiler in local MCP servers. | | `agent-bundle/cli-entry` | The routed-CLI shell every generated CLI executable is built on. | | `agent-bundle/mcp-entry` | The stdio MCP entry shell every generated MCP server is wrapped in. | +| `agent-bundle/launch-env` | The operator `.env` layer every emitted shell applies at launch (`applyOperatorEnv`, `parseOperatorEnv`, `AGENT_BUNDLE_ENV_FILE`), for hand-rolled entries that want the same behavior. | | `agent-bundle/routes` | The route-module authoring types (`AgentEventRouteProps`, `ToolRouteProps`, `CliRouteProps`, the `config` shapes) and `appResourceUri`, the static reference to a sibling MCP App the compiler resolves to its `resourceUri`. | Because it is generated, it always matches the shipped types: signatures, unions, defaults, and diff --git a/website/docs/en/reference/runtime-environment.mdx b/website/docs/en/reference/runtime-environment.mdx index 608a0ddf6..f0eafc5df 100644 --- a/website/docs/en/reference/runtime-environment.mdx +++ b/website/docs/en/reference/runtime-environment.mdx @@ -39,6 +39,7 @@ Cursor's pinned loader has its own substituted-field table, and a token outside | Variable | Read by | Meaning | | --- | --- | --- | | `AGENT_BUNDLE_PLUGIN_ROOT` | Generated executables | The plugin install root and durable-state anchor. Overrides the built-in fallback; surfaced to routes and providers as `(await agent()).plugin` (`source: 'native'`). An unexpanded `${…}` token is treated as unset. | +| `AGENT_BUNDLE_ENV_FILE` | Generated executables | The operator env file(s) an installed pack reads at launch instead of `/.env` and `.env.local`: one path, or several joined by the platform path delimiter, later files winning; `none` disables the layer. `mcp run` sets it for its child from `--env-file` / `--no-env`. | | `AGENT_BUNDLE_AGENT_API_TOKEN` | `agent-bundle dev` | The bearer token the Agent API requires before it can be enabled. | | `AGENT_BUNDLE_HOOK_HOST` | Generated hook wrappers | Pins the declared host explicitly instead of detecting it. | | `AGENT_BUNDLE_HOOK_SIMULATION` | Generated hook wrappers | `1` marks a simulated invocation; the Workbench hook playground sets it. | @@ -63,6 +64,21 @@ workspace root) — `.env`, `.env.local`, `.env.`, `.env..local` — `--env-file ` replaces it with exactly the files you name, or `--no-env` removes it. The two flags are mutually exclusive. Files are read into a scratch object, so the real `process.env` is never mutated. +## Operator `.env` files in installed packs + +A host launches an installed pack directly, so the pack's own shells apply the same layer +themselves: the stdio MCP entries, the hook wrappers that run plugin code, and the artifact CLI +`bin/.mjs` read `/.env` then `.env.local` at launch (or the files +`AGENT_BUNDLE_ENV_FILE` names), 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. The plugin root is `AGENT_BUNDLE_PLUGIN_ROOT` when the host +set it, otherwise the directory above the shell's own. No `${VAR}` interpolation is performed. +Under `mcp run` the plugin root is the project root, so the shell re-reads the files `mcp run` +already composed and changes nothing; `--env-file` and `--no-env` travel down as +`AGENT_BUNDLE_ENV_FILE`. The npm package bin reads no pack file. Doctor reports the presence and +variable count of each file (`AB7331`). Details and the precedence table: +[Installation](../guide/distribution/installation.mdx#operator-configuration-the-installed-packs-env). + ## Durable state Durable state resolves to `$AGENT_BUNDLE_PLUGIN_ROOT/state`, falling back to the artifact root, or diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index ad8da6f2c..29ad94e47 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -311,9 +311,11 @@ export default () => createRscMcpServer(application, 'curator'); 自行连接的入口——在顶层构造并连接传输层、且没有默认导出的模块——保持现有行为,逐字节不变。源码校验 会报告信息级的 `AB4730` 提示,建议升级为工厂函数写法;它永远不是错误。 -同一套生命周期对手写入口也是公开 API: +同一套生命周期对手写入口也是公开 API,生成外壳在导入服务器模块之前应用的操作者 `.env` 层同样如此 +(见[安装](../distribution/installation.mdx#操作者配置已安装包的-env)): ```ts +import { applyOperatorEnv } from 'agent-bundle/launch-env'; import { redirectConsoleToStderr, runStdioServer } from 'agent-bundle/mcp-entry'; ``` diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index be958ef74..e803fa243 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -83,6 +83,42 @@ node ./install.mjs 因为 `plugin update` 受版本门控;Codex 先 `codex plugin remove` 再 `add`。输出的 `INSTALL.md` 按宿主记录了 同样的步骤。 +## 操作者配置:已安装包的 `.env` + +通过环境变量配置的插件——会话 cookie、API 令牌、主机名——应在安装处配置,而不是在产物里:清单 `env` 是编译 +产物,按内容寻址且每次构建都会重新输出,不是放置密钥的地方。取而代之,在已安装副本的插件根目录(存放 +`.claude-plugin/`、`.cursor-plugin/` 或 Codex 插件清单、与 `mcp/`、`hooks/`、`bin/` 同级的目录)放一个 +`.env` 文件,可选地再放一个 `.env.local`: + +```sh +# ~/.cursor/plugins/local/movie-library/.env +IPT_SESSION=… +RTORRENT_SSH_HOST=nas.local +``` + +包输出的每个外壳——stdio MCP 服务器入口、运行插件代码的 hook 包装器、产物 CLI `bin/.mjs`——在启动时 +按同一条规则读取该文件: + +| 优先级 | 层 | 内容 | +| --- | --- | --- | +| 3(最高) | 宿主环境 | 宿主导出的变量以及其清单 `env` 声明的变量(包括 `AGENT_BUNDLE_PLUGIN_ROOT`)。导出的变量永远胜出。 | +| 2 | `.env.local` | 只填补空缺;同一个键上胜过 `.env`。 | +| 1(最低) | `.env` | 只填补空缺。 | + +位置是包解析出的插件根目录——宿主设置了 `AGENT_BUNDLE_PLUGIN_ROOT` 时取它,否则取外壳自身所在目录 +(`mcp/`、`hooks/`、`bin/`)的上一级。`AGENT_BUNDLE_ENV_FILE` 可指定改为读取的一个或多个文件(以平台路径 +分隔符分隔),`AGENT_BUNDLE_ENV_FILE=none` 则关闭这一层。语法即 dotenv:`KEY=value`、`export KEY=value`、 +`#` 注释、单引号、双引号或反引号包裹的值(双引号内展开 `\n`)、跨行的引号值,且不做 `${VAR}` 插值。 +文件缺失是常态且没有开销;无法读取的文件会被跳过,包照常启动。取值永远不会被记录。该文件不归安装回执 +所有,因此 `install --replace` 与同版本重建都会保留它,而 `uninstall` 绝不移除非归属条目:卸载后该文件仍会 +留下(Doctor 随后以 `AB7307` 把该目录报告为残留),凭据应当消失时请手动删除。路由与 provider 代码在运行时看到的是合成后的环境;在导入时就读取 `process.env` 的 hook 处理器或 +CLI 路由模块仍只看到宿主环境,因为这些模块是其包装器的静态导入——MCP 服务器模块在这一层之后加载, +处处可见。`agent-bundle mcp run` 自己也合成同样的三层,并把 `--env-file` / `--no-env` 以 +`AGENT_BUNDLE_ENV_FILE` 传给子进程,因此演练与已安装包读取的是同一组文件。 + +`agent-bundle doctor` 会报告已安装副本是否带有 `.env` 或 `.env.local` 以及各自声明了多少个变量 +(`AB7331`,信息级)——绝不报告名字或取值。 + ## 按回执卸载 ```sh diff --git a/website/docs/zh/reference/api.mdx b/website/docs/zh/reference/api.mdx index e6601bcf2..41a4b2560 100644 --- a/website/docs/zh/reference/api.mdx +++ b/website/docs/zh/reference/api.mdx @@ -23,6 +23,7 @@ description: '生成的 agent-bundle 类型 API:它覆盖哪些入口点、如 | `agent-bundle/mcp-apps` | MCP App 资源注册表契约,编译器会在本地 MCP 服务器中替换它。 | | `agent-bundle/cli-entry` | 每个生成的 CLI 可执行文件所基于的路由式 CLI 外壳。 | | `agent-bundle/mcp-entry` | 每个生成的 MCP 服务器所包裹的 stdio MCP 入口外壳。 | +| `agent-bundle/launch-env` | 每个输出外壳在启动时应用的操作者 `.env` 层(`applyOperatorEnv`、`parseOperatorEnv`、`AGENT_BUNDLE_ENV_FILE`),供希望获得同样行为的手写入口使用。 | | `agent-bundle/routes` | 路由模块的编写类型(`AgentEventRouteProps`、`ToolRouteProps`、`CliRouteProps` 与各类 `config` 形状),以及 `appResourceUri`——对同级 MCP App 的静态引用,编译器会把它解析为该 App 的 `resourceUri`。 | 因为它是生成的,所以它始终与已交付的类型一致:签名、联合类型、默认值,以及各表面可能抛出的错误类。 diff --git a/website/docs/zh/reference/runtime-environment.mdx b/website/docs/zh/reference/runtime-environment.mdx index ec8e0fc63..549e28be2 100644 --- a/website/docs/zh/reference/runtime-environment.mdx +++ b/website/docs/zh/reference/runtime-environment.mdx @@ -36,6 +36,7 @@ token 会在构建时报告 `AB6028`,并由 Doctor 报告 `AB7320`。 | 变量 | 由谁读取 | 含义 | | --- | --- | --- | | `AGENT_BUNDLE_PLUGIN_ROOT` | 生成式可执行文件 | 插件安装根目录与持久状态锚点。覆盖内置的回退值;以 `(await agent()).plugin`(`source: 'native'`)暴露给路由与 provider。未展开的 `${…}` token 视为未设置。 | +| `AGENT_BUNDLE_ENV_FILE` | 生成式可执行文件 | 已安装包在启动时改为读取的操作者 env 文件:一个路径,或以平台路径分隔符连接的多个路径(后者胜出),代替 `<插件根目录>/.env` 与 `.env.local`;`none` 关闭这一层。`mcp run` 会根据 `--env-file` / `--no-env` 为其子进程设置它。 | | `AGENT_BUNDLE_AGENT_API_TOKEN` | `agent-bundle dev` | Agent API 在启用之前所必需的 bearer token。 | | `AGENT_BUNDLE_HOOK_HOST` | 生成的钩子 wrapper | 显式指定声明的宿主,而不去探测。 | | `AGENT_BUNDLE_HOOK_SIMULATION` | 生成的钩子 wrapper | `1` 标记一次模拟调用;Workbench 的钩子 playground 会设置它。 | @@ -60,6 +61,18 @@ token 会在构建时报告 `AB6028`,并由 Doctor 报告 `AB7320`。 `--no-env` 把它移除。 这两个标志互斥。文件被读入一个临时对象,因此真实的 `process.env` 绝不会被改动。 +## 已安装包中的操作者 `.env` 文件 + +宿主直接启动已安装的包,因此包自己的外壳会自行应用同一层:stdio MCP 入口、运行插件代码的 hook 包装器 +以及产物 CLI `bin/.mjs` 在启动时读取 `<插件根目录>/.env` 再读取 `.env.local`(或 +`AGENT_BUNDLE_ENV_FILE` 指定的文件),只填补宿主未设置的变量——宿主环境与清单 `env` 胜出,`.env.local` +胜过 `.env`,取值永不记录,文件缺失没有开销,无法读取则跳过。插件根目录在宿主设置了 +`AGENT_BUNDLE_PLUGIN_ROOT` 时取它,否则取外壳自身所在目录的上一级。不做 `${VAR}` 插值。在 `mcp run` 下 +插件根目录就是项目根目录,因此外壳重读的正是 `mcp run` 已经合成过的文件,不会改变任何东西;`--env-file` +与 `--no-env` 以 `AGENT_BUNDLE_ENV_FILE` 传给子进程。npm 包 bin 不读取任何包文件。Doctor 会报告每个文件 +是否存在及其变量数量(`AB7331`)。细节与优先级表见 +[安装](../guide/distribution/installation.mdx#操作者配置已安装包的-env)。 + ## 持久状态 持久状态解析到 `$AGENT_BUNDLE_PLUGIN_ROOT/state`,回退到产物根目录,对 CLI bin 则回退到 diff --git a/website/rspress.config.ts b/website/rspress.config.ts index 46b6912e2..cd651b2b9 100644 --- a/website/rspress.config.ts +++ b/website/rspress.config.ts @@ -25,6 +25,7 @@ const publicApiEntryPoints = [ 'cli-entry.ts', 'config/index.ts', 'eval/index.ts', + 'launch-env.ts', 'mcp-apps.ts', 'meta.ts', 'mcp-entry.ts', From 55f8f4de8bbdb31ba95ddbc63150b92b4ac4cf10 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 06:55:11 +0000 Subject: [PATCH 2/6] chore: reference #538 in the changeset --- .changeset/469-operator-env-installed-packs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/469-operator-env-installed-packs.md b/.changeset/469-operator-env-installed-packs.md index e1de86b17..4e03b8262 100644 --- a/.changeset/469-operator-env-installed-packs.md +++ b/.changeset/469-operator-env-installed-packs.md @@ -2,4 +2,4 @@ 'agent-bundle': patch --- -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/.mjs` read `/.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) +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/.mjs` read `/.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) From 1f51e0a6b44f9a24e08eec2608616d4e4b83aff2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 07:11:35 +0000 Subject: [PATCH 3/6] fix(build): ignore the agent-bundle package root for the inlined launch-env module in bundle evidence --- packages/agent-bundle/src/build/cli-bins.ts | 2 +- packages/agent-bundle/src/build/entries.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/agent-bundle/src/build/cli-bins.ts b/packages/agent-bundle/src/build/cli-bins.ts index 16efa6864..ddf5c6441 100644 --- a/packages/agent-bundle/src/build/cli-bins.ts +++ b/packages/agent-bundle/src/build/cli-bins.ts @@ -196,7 +196,7 @@ export const planCliBinsSurface = ( }), }))); }, - ignoredSourcePaths: [runtimeIgnoredRoot(cliEntryRuntimePath()), launchEnvRuntimePath()], + ignoredSourcePaths: [runtimeIgnoredRoot(cliEntryRuntimePath()), runtimeIgnoredRoot(launchEnvRuntimePath())], logLevel: 'error', }; }; diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index e207046d1..52f58f046 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -492,7 +492,9 @@ export const planMcpEntriesSurface = async ( : { ignoredSourcePaths: [ ...(runtimeShell === undefined ? [] : [runtimeShell]), - ...(launchEnvRuntime === undefined ? [] : [launchEnvRuntime]), + // The package root, not the file: from the built package the + // module shares chunks with its siblings under `dist/`. + ...(launchEnvRuntime === undefined ? [] : [runtimeIgnoredRoot(launchEnvRuntime)]), ...(eventIpcRuntime === undefined ? [] : [runtimeIgnoredRoot(eventIpcRuntime)]), ...(serverRuntime === undefined ? [] : [runtimeIgnoredRoot(serverRuntime)]), ], @@ -625,7 +627,7 @@ export const planHooksSurface = ( ...(workerEntry === undefined ? [] : [workerEntry]), ], ignoredSourcePaths: [ - launchEnvRuntime, + runtimeIgnoredRoot(launchEnvRuntime), ...(eventIpcRuntime === undefined ? [] : [runtimeIgnoredRoot(eventIpcRuntime)]), ], finish: async (evidence) => { From 9e8dde44469632fabda8c9890f76219618f7efd9 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 18:32:06 +0000 Subject: [PATCH 4/6] fix(launch-env): parse inline comments after a closing quote, reserve host keys case-insensitively on Windows, resolve AGENT_BUNDLE_PLUGIN_ROOT as written; document that self-connecting entries carry no operator env layer (review) --- .../469-operator-env-installed-packs.md | 2 +- docs/entry-conventions.md | 9 ++- packages/agent-bundle/src/launch-env.ts | 77 +++++++++++++------ .../agent-bundle/tests/launch-env.test.ts | 35 +++++++++ website/docs/en/guide/authoring/mcp.mdx | 9 ++- .../en/guide/distribution/installation.mdx | 6 +- website/docs/zh/guide/authoring/mcp.mdx | 6 +- .../zh/guide/distribution/installation.mdx | 5 +- 8 files changed, 114 insertions(+), 35 deletions(-) diff --git a/.changeset/469-operator-env-installed-packs.md b/.changeset/469-operator-env-installed-packs.md index 4e03b8262..ea31d7490 100644 --- a/.changeset/469-operator-env-installed-packs.md +++ b/.changeset/469-operator-env-installed-packs.md @@ -2,4 +2,4 @@ 'agent-bundle': patch --- -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/.mjs` read `/.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) +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 and applies `applyOperatorEnv` itself if it wants the layer), every hook wrapper that runs plugin code, and the artifact CLI `bin/.mjs` read `/.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) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 79fede859..53fa4a7bb 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -944,7 +944,9 @@ 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. Every served tool call is one ordinary `tools/call`: optional `notifications/progress` while the caller's progress token is live, then one @@ -1271,8 +1273,9 @@ canonical precedence order (highest wins): | 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 (before its deferred server -import), the hook wrappers that execute handlers or render standalone, and the +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/.mjs` — applies `agent-bundle/launch-env` (`src/launch-env.ts`, plain Node, inlined into the bundle) at startup. It reads `/.env` then `.env.local`, where the plugin root is the diff --git a/packages/agent-bundle/src/launch-env.ts b/packages/agent-bundle/src/launch-env.ts index d514da0af..556c79144 100644 --- a/packages/agent-bundle/src/launch-env.ts +++ b/packages/agent-bundle/src/launch-env.ts @@ -45,6 +45,12 @@ export interface OperatorEnvResult { export interface OperatorEnvOptions { /** The environment to fill; `process.env` by default. Mutated in place. */ readonly env?: NodeJS.ProcessEnv; + /** + * The platform whose environment-key rules apply; `process.platform` by + * default. Windows environment names are case-insensitive, so a host `Path` + * reserves `PATH` there. + */ + readonly platform?: NodeJS.Platform; /** * The plugin root the files live under: the expanded `AGENT_BUNDLE_PLUGIN_ROOT` * when the host set one, otherwise the shell's own fallback (the artifact root @@ -60,13 +66,15 @@ const unexpandedToken = /\$\{[^}]*\}/u; * precedence the runtime's `resolvePluginRoot` applies (an expanded * `AGENT_BUNDLE_PLUGIN_ROOT`, else the shell's fallback), spelled here without * a runtime import so the shared-runtime hook wrapper stays dependency-free. + * Like the runtime, only the blank check reads a trimmed copy: a configured + * path is resolved exactly as written, so both resolutions name one directory. */ export const operatorEnvPluginRoot = ( fallback: string, env: Readonly> = process.env, ): string => { - const declared = env['AGENT_BUNDLE_PLUGIN_ROOT']?.trim() ?? ''; - return declared === '' || unexpandedToken.test(declared) ? resolve(fallback) : resolve(declared); + const declared = env['AGENT_BUNDLE_PLUGIN_ROOT'] ?? ''; + return declared.trim() === '' || unexpandedToken.test(declared) ? resolve(fallback) : resolve(declared); }; /** @@ -86,27 +94,36 @@ export const operatorEnvFilePaths = ( return Object.freeze(OPERATOR_ENV_FILE_NAMES.map((name) => join(pluginRoot, name))); }; -const unquote = (raw: string): string => { - const value = raw.trim(); - if (value.length >= 2) { - const quote = value[0]; - if ((quote === '"' || quote === "'" || quote === '`') && value.endsWith(quote)) { - const inner = value.slice(1, -1); - return quote === '"' ? inner.replace(/\\n/gu, '\n').replace(/\\r/gu, '\r').replace(/\\"/gu, '"') : inner; +/** The index of the first unescaped `quote` after the opening one, or -1 while the value is still open. */ +const closingQuoteIndex = (raw: string, quote: string): number => { + for (let index = 1; index < raw.length; index += 1) { + if (raw[index] === '\\') { + index += 1; + continue; } + if (raw[index] === quote) return index; } - // An unquoted value ends at the first ` #` comment. + return -1; +}; + +/** An unquoted value ends at the first ` #` comment. */ +const unquotedValue = (raw: string): string => { + const value = raw.trim(); const comment = value.search(/\s#/u); return (comment === -1 ? value : value.slice(0, comment)).trim(); }; +const quotedValue = (quote: string, inner: string): string => + quote === '"' ? inner.replace(/\\n/gu, '\n').replace(/\\r/gu, '\r').replace(/\\"/gu, '"') : inner; + /** * The dotenv grammar the shells accept: `KEY=value` lines, an optional - * `export ` prefix, blank lines and `#` comments, single-, double-, or - * backtick-quoted values (double quotes expand `\n`, `\r`, `\"`), and a - * multi-line double- or single-quoted value that closes on a later line. No - * `${VAR}` interpolation — a value is used exactly as written. Later lines - * win over earlier ones for the same key. + * `export ` prefix, blank lines and `#` comments (including after a closing + * quote: `TOKEN="secret" # note`), single-, double-, or backtick-quoted values + * (double quotes expand `\n`, `\r`, `\"`), and a multi-line double- or + * single-quoted value that closes on a later line. No `${VAR}` interpolation + * — a value is used exactly as written. Later lines win over earlier ones for + * the same key. */ export const parseOperatorEnv = (contents: string): Record => { const parsed: Record = {}; @@ -119,15 +136,26 @@ export const parseOperatorEnv = (contents: string): Record => { const key = match[1]!; let raw = match[2]!.trim(); const quote = raw[0]; - if (quote === '"' || quote === "'") { - // A quoted value continues until a line ends with the closing quote. - const closed = (): boolean => raw.length >= 2 && raw.endsWith(quote) && !raw.endsWith(`\\${quote}`); - while (!closed() && index + 1 < lines.length) { + if (quote === '"' || quote === "'" || quote === '`') { + // A double- or single-quoted value continues onto later lines until its + // closing quote; what follows the closing quote may only be a comment. + let end = closingQuoteIndex(raw, quote); + while (end === -1 && quote !== '`' && index + 1 < lines.length) { index += 1; raw += `\n${lines[index]!}`; + end = closingQuoteIndex(raw, quote); + } + if (end !== -1) { + const trailer = raw.slice(end + 1).trim(); + if (trailer === '' || trailer.startsWith('#')) { + parsed[key] = quotedValue(quote, raw.slice(1, end)); + continue; + } } } - parsed[key] = unquote(raw); + // Unterminated or followed by something other than a comment: dotenv reads + // the line as an unquoted value, quotes and all. + parsed[key] = unquotedValue(raw); } return parsed; }; @@ -149,7 +177,12 @@ const readOptional = (path: string): string | undefined | null => { */ export const applyOperatorEnv = (options: OperatorEnvOptions): OperatorEnvResult => { const env = options.env ?? process.env; - const reserved = new Set(Object.keys(env).filter((key) => env[key] !== undefined)); + // Windows environment names are case-insensitive: a host `Path` reserves + // `PATH`, so the file may not overwrite it under another spelling. + const reservedKey = (options.platform ?? process.platform) === 'win32' + ? (key: string): string => key.toUpperCase() + : (key: string): string => key; + const reserved = new Set(Object.keys(env).filter((key) => env[key] !== undefined).map(reservedKey)); const files: OperatorEnvFile[] = []; const applied = new Set(); for (const path of operatorEnvFilePaths(options.pluginRoot, env)) { @@ -164,7 +197,7 @@ export const applyOperatorEnv = (options: OperatorEnvOptions): OperatorEnvResult } let count = 0; for (const [key, value] of Object.entries(parseOperatorEnv(contents))) { - if (reserved.has(key)) continue; + if (reserved.has(reservedKey(key))) continue; env[key] = value; applied.add(key); count += 1; diff --git a/packages/agent-bundle/tests/launch-env.test.ts b/packages/agent-bundle/tests/launch-env.test.ts index 3b3c52816..469724ad6 100644 --- a/packages/agent-bundle/tests/launch-env.test.ts +++ b/packages/agent-bundle/tests/launch-env.test.ts @@ -59,10 +59,45 @@ describe('the operator .env layer of an installed pack (#469)', () => { }); }); + it('reads an inline comment after a closing quote and keeps a quoted value on one line', () => { + expect(parseOperatorEnv([ + 'TOKEN="s3cr3t" # operator note', + "SINGLE='one' # note", + 'TICK=`two` # note', + 'ESCAPED="a \\" b" # note', + 'NEXT=still-parsed', + 'TRAILER="x" not-a-comment', + 'OPEN="never closed', + ].join('\n'))).toEqual({ + ESCAPED: 'a " b', + NEXT: 'still-parsed', + // dotenv reads a quoted value followed by anything but a comment as an unquoted literal. + OPEN: '"never closed', + SINGLE: 'one', + TICK: 'two', + TOKEN: 's3cr3t', + TRAILER: '"x" not-a-comment', + }); + }); + + it('reserves host keys case-insensitively on Windows only', async () => { + const root = await createRoot(); + await writeFile(join(root, '.env'), 'PATH=from-file\nAPI_KEY=from-file\nOTHER=1\n'); + const windows: NodeJS.ProcessEnv = { Api_Key: 'host', Path: 'host' }; + expect(applyOperatorEnv({ env: windows, platform: 'win32', pluginRoot: root }).applied).toEqual(['OTHER']); + expect(windows).toEqual({ Api_Key: 'host', OTHER: '1', Path: 'host' }); + + const posix: NodeJS.ProcessEnv = { Api_Key: 'host', Path: 'host' }; + expect(applyOperatorEnv({ env: posix, platform: 'linux', pluginRoot: root }).applied).toEqual(['API_KEY', 'OTHER', 'PATH']); + expect(posix).toEqual({ API_KEY: 'from-file', Api_Key: 'host', OTHER: '1', PATH: 'from-file', Path: 'host' }); + }); + it('resolves the plugin root with the anchor precedence the runtime uses', () => { expect(operatorEnvPluginRoot('/artifact/claude', {})).toBe('/artifact/claude'); expect(operatorEnvPluginRoot('/artifact/claude', { AGENT_BUNDLE_PLUGIN_ROOT: '/installs/curator' })).toBe('/installs/curator'); expect(operatorEnvPluginRoot('/artifact/claude', { AGENT_BUNDLE_PLUGIN_ROOT: ' ' })).toBe('/artifact/claude'); + // A configured path is resolved exactly as written, like the runtime's resolvePluginRoot (#468). + expect(operatorEnvPluginRoot('/artifact/claude', { AGENT_BUNDLE_PLUGIN_ROOT: '/installs/curator ' })).toBe('/installs/curator '); // An unexpanded host token is treated as unset, never joined into a path. expect(operatorEnvPluginRoot('/artifact/claude', { AGENT_BUNDLE_PLUGIN_ROOT: '${CLAUDE_PLUGIN_ROOT}' })).toBe('/artifact/claude'); expect(operatorEnvPluginRoot('/artifact/claude', { AGENT_BUNDLE_PLUGIN_ROOT: './' })).toBe(resolve('./')); diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index 177b3c993..076590cc1 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -344,12 +344,15 @@ That guard matters because stdout carries JSON-RPC framing: one stray `console.l imported module would corrupt the protocol stream. Self-connecting entries — modules that construct and connect a transport at top level without a -default export — keep their existing behavior byte for byte. Source validation reports the -informational `AB4730` nudge suggesting the factory upgrade; it is never an error. +default export — keep their existing behavior byte for byte: no lifecycle shell, and no operator +`.env` layer, since there is no shell to apply it before the module's own top-level code runs. +Source validation reports the informational `AB4730` nudge suggesting the factory upgrade; it is +never an error. The same lifecycle is public API for hand-rolled entries, and so is the operator `.env` layer the generated shell applies before it imports the server module (see -[Installation](../distribution/installation.mdx#operator-configuration-the-installed-packs-env)): +[Installation](../distribution/installation.mdx#operator-configuration-the-installed-packs-env)) — +a self-connecting entry that needs the layer calls `applyOperatorEnv` itself, first thing: ```ts import { applyOperatorEnv } from 'agent-bundle/launch-env'; diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index bbff4ca52..20527ec87 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -113,8 +113,10 @@ IPT_SESSION=… RTORRENT_SSH_HOST=nas.local ``` -Every shell the pack emits reads that file when it starts — the stdio MCP server entries, the -hook wrappers that run plugin code, and the artifact CLI `bin/.mjs` — under one rule: +Every shell the pack emits reads that file when it starts — the stdio MCP server entries (factory +exports; a [self-connecting entry](../authoring/mcp.mdx#handwritten-stdio-entries) has no shell and +reads none unless it calls `applyOperatorEnv` itself), the hook wrappers that run plugin code, and +the artifact CLI `bin/.mjs` — under one rule: | Precedence | Layer | Contents | | --- | --- | --- | diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index 29ad94e47..e2ba00cde 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -308,11 +308,13 @@ export default () => createRscMcpServer(application, 'curator'); 这层保护之所以重要,是因为 stdout 承载着 JSON-RPC 帧:任何被导入模块中一次走神的 `console.log` 都会 破坏协议流。 -自行连接的入口——在顶层构造并连接传输层、且没有默认导出的模块——保持现有行为,逐字节不变。源码校验 +自行连接的入口——在顶层构造并连接传输层、且没有默认导出的模块——保持现有行为,逐字节不变:没有生命周期 +外壳,也没有操作者 `.env` 层,因为没有外壳能在模块自身的顶层代码运行之前应用它。源码校验 会报告信息级的 `AB4730` 提示,建议升级为工厂函数写法;它永远不是错误。 同一套生命周期对手写入口也是公开 API,生成外壳在导入服务器模块之前应用的操作者 `.env` 层同样如此 -(见[安装](../distribution/installation.mdx#操作者配置已安装包的-env)): +(见[安装](../distribution/installation.mdx#操作者配置已安装包的-env))——需要这一层的自行连接入口 +应在第一行自行调用 `applyOperatorEnv`: ```ts import { applyOperatorEnv } from 'agent-bundle/launch-env'; diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index e803fa243..c3d695dfd 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -96,8 +96,9 @@ IPT_SESSION=… RTORRENT_SSH_HOST=nas.local ``` -包输出的每个外壳——stdio MCP 服务器入口、运行插件代码的 hook 包装器、产物 CLI `bin/.mjs`——在启动时 -按同一条规则读取该文件: +包输出的每个外壳——stdio MCP 服务器入口(工厂导出;[自行连接的入口](../authoring/mcp.mdx#手写-stdio-入口) +没有外壳,除非自行调用 `applyOperatorEnv`,否则不读取任何文件)、运行插件代码的 hook 包装器、产物 CLI +`bin/.mjs`——在启动时按同一条规则读取该文件: | 优先级 | 层 | 内容 | | --- | --- | --- | From 93584ef0e37fc71fbc628e1f136914fc5da31c6e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 18:41:40 +0000 Subject: [PATCH 5/6] test(hooks): strip the operator env import and statement from the wrapper internals the contract test evaluates --- packages/agent-bundle/tests/hook-handler-contract.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/agent-bundle/tests/hook-handler-contract.test.ts b/packages/agent-bundle/tests/hook-handler-contract.test.ts index 77acd460f..c685a386d 100644 --- a/packages/agent-bundle/tests/hook-handler-contract.test.ts +++ b/packages/agent-bundle/tests/hook-handler-contract.test.ts @@ -22,6 +22,7 @@ import { type HookResult, } from '../src/adapters/hook-handler.ts'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; +import { launchEnvRuntimeSpecifier, operatorEnvStatement } from '../src/build/launch-env-shell.ts'; import { canonicalHookEvents } from '../src/core/types.ts'; import type { CanonicalAgentEvent } from '../src/routes/public.ts'; @@ -72,10 +73,13 @@ const wrapperInternalsSource = (host: Host, entry: TargetHookWrapper): string => : nativeHookWrapperSource(entry, host === 'claude' ? 'Claude' : 'Codex'); const mainIndex = source.indexOf('if (import.meta.main) {'); expect(mainIndex).toBeGreaterThan(0); + // The handler module and the operator `.env` layer (#469) are the wrapper's + // bundled runtime; the codec internals under test need neither. const body = source .slice(0, mainIndex) .split('\n') .filter((line) => !line.startsWith('import * as handlerModule from ')) + .filter((line) => !line.includes(JSON.stringify(launchEnvRuntimeSpecifier)) && line !== operatorEnvStatement) .join('\n'); const decoder = host === 'cursor' ? 'decodeCursorNative' : 'decodeNative'; return `${body}\nexport { ${decoder} as decodeNative, validateNativeInput, validateResult };\n`; From ab9818aa7665597697b00c064e250a53a8209598 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 19:14:59 +0000 Subject: [PATCH 6/6] fix(build): alias agent-bundle/launch-env into self-connecting stdio MCP entries so they can apply the operator .env layer themselves (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A self-connecting entry has no lifecycle shell, so the docs told it to import `agent-bundle/launch-env` and call `applyOperatorEnv` itself — but the alias was set only for wrapped entries, so that import resolved only when the plugin's own node_modules happened to expose the package (a temp project failed with "Can't resolve 'agent-bundle/launch-env'"). `planMcpEntriesSurface` and the `inspect --bundler` mirror now alias the module into every stdio entry: inlined only where an import reaches it (an entry that never imports it is byte-identical), excluded from authored-source evidence, and a reserved specifier a `tools` hatch cannot externalize. mcp.test.ts builds such an entry and runs it under node: `/.env` fills a gap, an exported variable wins, `AGENT_BUNDLE_ENV_FILE=none` disables the layer, and the bundle is self-contained and shell-free. The docs (mcp.mdx en+zh, entry-conventions.md) and the changeset show the full recipe with `operatorEnvPluginRoot`. --- .../469-operator-env-installed-packs.md | 2 +- docs/entry-conventions.md | 5 +- packages/agent-bundle/src/build/entries.ts | 47 ++++++++------- .../agent-bundle/src/build/inspect-bundler.ts | 16 +++-- packages/agent-bundle/tests/mcp.test.ts | 60 +++++++++++++++++++ website/docs/en/guide/authoring/mcp.mdx | 17 +++++- website/docs/zh/guide/authoring/mcp.mdx | 15 ++++- 7 files changed, 126 insertions(+), 36 deletions(-) diff --git a/.changeset/469-operator-env-installed-packs.md b/.changeset/469-operator-env-installed-packs.md index ea31d7490..d4a2ca425 100644 --- a/.changeset/469-operator-env-installed-packs.md +++ b/.changeset/469-operator-env-installed-packs.md @@ -2,4 +2,4 @@ '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 and applies `applyOperatorEnv` itself if it wants the layer), every hook wrapper that runs plugin code, and the artifact CLI `bin/.mjs` read `/.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) +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/.mjs` read `/.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) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 86bd766fd..59f3d3645 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -979,7 +979,10 @@ 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: no lifecycle shell and no operator `.env` layer (#469); an entry that wants the -layer calls `applyOperatorEnv` from `agent-bundle/launch-env` itself. +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 diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 52f58f046..f4138c207 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -427,21 +427,26 @@ export const planMcpEntriesSurface = async ( : undefined; })); const runtimeShell = entryShells.some((shell) => shell !== undefined) ? mcpEntryRuntimePath() : undefined; - // The stdio lifecycle shell applies the operator `.env` layer (#469); the - // plain-Node module is inlined beside it. - const launchEnvRuntime = runtimeShell === undefined ? undefined : launchEnvRuntimePath(); + // 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, - ...(launchEnvRuntime === undefined ? {} : { [launchEnvRuntimeSpecifier]: launchEnvRuntime }), ...(id !== eventHostId || eventIpcRuntime === undefined || eventProjectRuntime === undefined ? {} : { @@ -451,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 }), @@ -487,18 +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]), - // The package root, not the file: from the built package the - // module shares chunks with its siblings under `dist/`. - ...(launchEnvRuntime === undefined ? [] : [runtimeIgnoredRoot(launchEnvRuntime)]), - ...(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({ diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index f9396e9d0..c1cd1d6f0 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -225,13 +225,19 @@ const mcpEntryEntries = async ( }); entries.push(rslibInspectionEntry({ entry: { - ...(wrapped - ? { - aliases: { - [launchEnvRuntimeSpecifier]: launchEnvRuntimePath(), + aliases: { + // Every stdio entry can import the operator `.env` layer (#469); the + // lifecycle shell of a wrapped entry applies it itself. + [launchEnvRuntimeSpecifier]: launchEnvRuntimePath(), + ...(wrapped + ? { [mcpEntryRuntimeSpecifier]: mcpEntryRuntimePath(), ...(routeSource === undefined ? {} : { [mcpServerRuntimeSpecifier]: mcpServerRuntimePath() }), - }, + } + : {}), + }, + ...(wrapped + ? { virtualSource: generatedStdioMcpEntrySource({ entrySource: routeSource === undefined ? entry.source : 'agent-bundle/generated-route-server', serverName, diff --git a/packages/agent-bundle/tests/mcp.test.ts b/packages/agent-bundle/tests/mcp.test.ts index ec00859ed..c79c3328b 100644 --- a/packages/agent-bundle/tests/mcp.test.ts +++ b/packages/agent-bundle/tests/mcp.test.ts @@ -25,6 +25,7 @@ import { import { agentBundleNodeModules, agentBundlePackageRoot, workbenchNodeModules } from './helpers/workspace-paths.ts'; import { loadedProject } from './support/loaded-project.ts'; +import { runNodeScript } from './support/run-node-script.ts'; const registry: NormalizationTargetRegistry = { configExtensions: () => [], @@ -681,6 +682,65 @@ it('bundles each local MCP entry once and maps every target manifest to that art } }, 30_000); +it('inlines agent-bundle/launch-env into a self-connecting entry so it can apply the operator .env layer itself (#469)', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-self-connecting-env-')); + try { + await mkdir(join(root, 'src'), { recursive: true }); + await writeFile(join(root, 'agent-bundle.config.ts'), 'export default {};\n'); + // No default export: the entry gets no lifecycle shell, so it applies the + // layer first thing, anchored exactly as a shell would (the documented recipe). + await writeFile(join(root, 'src', 'probe.ts'), [ + "import { fileURLToPath } from 'node:url';", + "import { applyOperatorEnv, operatorEnvPluginRoot } from 'agent-bundle/launch-env';", + '', + "const layer = applyOperatorEnv({ pluginRoot: operatorEnvPluginRoot(fileURLToPath(new URL('..', import.meta.url))) });", + "process.stdout.write(`${JSON.stringify({ applied: layer.applied, file: process.env.PROBE_FILE ?? null, host: process.env.PROBE_HOST ?? null })}\\n`);", + '', + ].join('\n')); + const model = await normalizeProject( + loadedProject(root, { + mcp: { servers: { probe: { entry: './src/probe.ts' } } }, + plugin: { name: 'mcp-self-connecting-env', version: '1.0.0' }, + targets: ['portable'], + }), + { skills: [] }, + registry, + ); + const outputRoot = join(root, 'artifact'); + const result = await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + expect(await validateArtifact({ artifactRoot: outputRoot })).toEqual([]); + const [entry] = result.compiledMcpEntries; + // The inlined loader is framework runtime, never authored-source evidence. + expect(entry).toMatchObject({ + id: 'mcp:probe', + sourceInputs: [join(root, 'agent-bundle.config.ts'), join(root, 'src', 'probe.ts')], + target: 'portable', + }); + + const bundle = await readFile(entry!.output, 'utf8'); + // Self-contained (the alias resolved to the framework's own module) and + // still shell-free: the entry's own top-level code is what runs first. + expect(bundle).not.toMatch(/from\s*["']agent-bundle\//u); + expect(bundle).not.toContain('runGeneratedStdioMcpEntry'); + expect(bundle).toContain('AGENT_BUNDLE_ENV_FILE'); + + // `/.env` is one directory above `mcp/`; it fills the gap and + // an exported variable still wins. + const pluginRoot = join(outputRoot, 'portable'); + const probe = async (env: Readonly>): Promise => { + const run = await runNodeScript({ args: [entry!.output], env }); + expect(run).toMatchObject({ code: 0, stderr: '' }); + return JSON.parse(run.stdout); + }; + expect(await probe({ PROBE_HOST: 'from-host' })).toEqual({ applied: [], file: null, host: 'from-host' }); + await writeFile(join(pluginRoot, '.env'), 'PROBE_FILE=from-file\nPROBE_HOST=from-file\n'); + expect(await probe({ PROBE_HOST: 'from-host' })).toEqual({ applied: ['PROBE_FILE'], file: 'from-file', host: 'from-host' }); + expect(await probe({ AGENT_BUNDLE_ENV_FILE: 'none', PROBE_HOST: 'from-host' })).toEqual({ applied: [], file: null, host: 'from-host' }); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 30_000); + it('builds one deterministic self-contained MCP App view and injects it through the virtual module', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-app-build-')); try { diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index e00be56d2..a80a9b814 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -450,14 +450,25 @@ never an error. The same lifecycle is public API for hand-rolled entries, and so is the operator `.env` layer the generated shell applies before it imports the server module (see -[Installation](../distribution/installation.mdx#operator-configuration-the-installed-packs-env)) — -a self-connecting entry that needs the layer calls `applyOperatorEnv` itself, first thing: +[Installation](../distribution/installation.mdx#operator-configuration-the-installed-packs-env)). +Both imports are inlined into the emitted entry, which stays self-contained; +`agent-bundle/launch-env` is aliased into every stdio entry, so it resolves whether or not the +entry has a shell. A self-connecting entry that needs the layer applies it first thing, anchored +the way the generated shell is (the artifact root is the parent of `mcp/`): ```ts -import { applyOperatorEnv } from 'agent-bundle/launch-env'; +import { fileURLToPath } from 'node:url'; +import { applyOperatorEnv, operatorEnvPluginRoot } from 'agent-bundle/launch-env'; import { redirectConsoleToStderr, runStdioServer } from 'agent-bundle/mcp-entry'; + +applyOperatorEnv({ + pluginRoot: operatorEnvPluginRoot(fileURLToPath(new URL('..', import.meta.url))), +}); ``` +`operatorEnvPluginRoot` prefers an `AGENT_BUNDLE_PLUGIN_ROOT` the host set over the directory you +pass, so the entry reads the same files as the pack's hook wrappers and CLI. + ## Declaring servers in config Declare a server when you need something the convention cannot express — a different entry path, diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index a1f9967c4..fcfbf48a7 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -399,14 +399,23 @@ export default () => createRscMcpServer(application, 'curator'); 会报告信息级的 `AB4730` 提示,建议升级为工厂函数写法;它永远不是错误。 同一套生命周期对手写入口也是公开 API,生成外壳在导入服务器模块之前应用的操作者 `.env` 层同样如此 -(见[安装](../distribution/installation.mdx#操作者配置已安装包的-env))——需要这一层的自行连接入口 -应在第一行自行调用 `applyOperatorEnv`: +(见[安装](../distribution/installation.mdx#操作者配置已安装包的-env))。这两个导入都会被内联进产出的 +入口,入口保持自包含;`agent-bundle/launch-env` 会被别名到每一个 stdio 入口,因此无论入口有没有外壳都能 +解析。需要这一层的自行连接入口应在第一行自行应用它,并像生成外壳一样锚定(产物根目录是 `mcp/` 的上一级): ```ts -import { applyOperatorEnv } from 'agent-bundle/launch-env'; +import { fileURLToPath } from 'node:url'; +import { applyOperatorEnv, operatorEnvPluginRoot } from 'agent-bundle/launch-env'; import { redirectConsoleToStderr, runStdioServer } from 'agent-bundle/mcp-entry'; + +applyOperatorEnv({ + pluginRoot: operatorEnvPluginRoot(fileURLToPath(new URL('..', import.meta.url))), +}); ``` +`operatorEnvPluginRoot` 会优先采用宿主设置的 `AGENT_BUNDLE_PLUGIN_ROOT`,否则回退到你传入的目录, +因此该入口读取的文件与同一个包的钩子包装器和 CLI 完全一致。 + ## 在配置中声明服务器 当你需要约定无法表达的东西时再声明服务器——不同的入口路径、target 限制、额外环境变量,或者一个你