diff --git a/packages/coding-agent/src/core/agent-session-runtime.ts b/packages/coding-agent/src/core/agent-session-runtime.ts index 9822e4f28b..3fe935a5d3 100644 --- a/packages/coding-agent/src/core/agent-session-runtime.ts +++ b/packages/coding-agent/src/core/agent-session-runtime.ts @@ -87,6 +87,7 @@ function extractUserMessageText(content: string | Array<{ type: string; text?: s */ export class AgentSessionRuntime implements SubagentRuntimeHost { private rebindSession?: (session: AgentSession) => Promise; + private runtimeEnvScope?: (fn: () => Promise) => Promise; private beforeSessionInvalidate?: () => void; private subagentRuntimeHost?: SubagentRuntimeHost; private subagentRuntimes = new Map(); @@ -142,6 +143,19 @@ export class AgentSessionRuntime implements SubagentRuntimeHost { this.rebindSession = rebindSession; } + /** + * Host-installed scope wrapping every runtime rebuild (new/switch/fork/ + * import and subagent creation), during which extensions re-load. The + * daemon uses it to apply the session's client env for load-time captures. + */ + setRuntimeEnvScope(scope?: (fn: () => Promise) => Promise): void { + this.runtimeEnvScope = scope; + } + + private scopedBuild(fn: () => Promise): Promise { + return this.runtimeEnvScope ? this.runtimeEnvScope(fn) : fn(); + } + setSubagentRuntimeHost(host?: SubagentRuntimeHost): void { this.subagentRuntimeHost = host; this.bindRuntimeHost(); @@ -260,37 +274,39 @@ export class AgentSessionRuntime implements SubagentRuntimeHost { if (options.parentSession.sessionFile) { sessionManager.newSession({ parentSession: options.parentSession.sessionFile }); } - const runtime = await createAgentSessionRuntime(this.createRuntime, { - cwd: sessionManager.getCwd(), - agentDir: this.services.agentDir, - sessionManager, - sessionStartEvent: { type: "session_start", reason: "startup" }, - sessionConfig: this.sessionConfig, - sessionOptions: { - model: options.model, - thinkingLevel: options.thinkingLevel, - scopedModels: options.scopedModels, - initialActiveToolNames: options.activeToolNames, - allowedToolNames: options.allowedToolNames, - customTools: options.customTools, - includeGoals: options.includeGoals, - rlmDepth: options.rlmDepth, - rlmMaxDepth: options.rlmMaxDepth, - rlmSessionDir: options.sessionDir, - rlmParentNodeId: options.rlmParentNodeId, - }, - runtimeMetadata: { - kind: "subagent", - createdAt: Date.now(), - parentSessionId: options.parentSession.sessionId, - parentSessionFile: options.parentSession.sessionFile, - rlmChildId: options.id, - rlmParentNodeId: options.rlmParentNodeId, - prompt: options.prompt, - spawnCode: options.spawnCode, - sessionDir: options.sessionDir, - }, - }); + const runtime = await this.scopedBuild(() => + createAgentSessionRuntime(this.createRuntime, { + cwd: sessionManager.getCwd(), + agentDir: this.services.agentDir, + sessionManager, + sessionStartEvent: { type: "session_start", reason: "startup" }, + sessionConfig: this.sessionConfig, + sessionOptions: { + model: options.model, + thinkingLevel: options.thinkingLevel, + scopedModels: options.scopedModels, + initialActiveToolNames: options.activeToolNames, + allowedToolNames: options.allowedToolNames, + customTools: options.customTools, + includeGoals: options.includeGoals, + rlmDepth: options.rlmDepth, + rlmMaxDepth: options.rlmMaxDepth, + rlmSessionDir: options.sessionDir, + rlmParentNodeId: options.rlmParentNodeId, + }, + runtimeMetadata: { + kind: "subagent", + createdAt: Date.now(), + parentSessionId: options.parentSession.sessionId, + parentSessionFile: options.parentSession.sessionFile, + rlmChildId: options.id, + rlmParentNodeId: options.rlmParentNodeId, + prompt: options.prompt, + spawnCode: options.spawnCode, + sessionDir: options.sessionDir, + }, + }), + ); this.subagentRuntimes.set(options.id, runtime); try { await runtime.session.bindExtensions({}); @@ -352,13 +368,15 @@ export class AgentSessionRuntime implements SubagentRuntimeHost { assertSessionCwdExists(sessionManager, this.cwd); await this.teardownCurrent("resume", sessionManager.getSessionFile()); this.apply( - await this.createRuntime({ - cwd: sessionManager.getCwd(), - agentDir: this.services.agentDir, - sessionManager, - sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile }, - sessionConfig: this.sessionConfig, - }), + await this.scopedBuild(() => + this.createRuntime({ + cwd: sessionManager.getCwd(), + agentDir: this.services.agentDir, + sessionManager, + sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile }, + sessionConfig: this.sessionConfig, + }), + ), ); await this.finishSessionReplacement(options?.withSession); return { cancelled: false }; @@ -383,13 +401,15 @@ export class AgentSessionRuntime implements SubagentRuntimeHost { await this.teardownCurrent("new", sessionManager.getSessionFile()); this.apply( - await this.createRuntime({ - cwd: this.cwd, - agentDir: this.services.agentDir, - sessionManager, - sessionStartEvent: { type: "session_start", reason: "new", previousSessionFile }, - sessionConfig: this.sessionConfig, - }), + await this.scopedBuild(() => + this.createRuntime({ + cwd: this.cwd, + agentDir: this.services.agentDir, + sessionManager, + sessionStartEvent: { type: "session_start", reason: "new", previousSessionFile }, + sessionConfig: this.sessionConfig, + }), + ), ); if (options?.setup) { await options.setup(this.session.sessionManager); @@ -438,13 +458,15 @@ export class AgentSessionRuntime implements SubagentRuntimeHost { sessionManager.newSession({ parentSession: currentSessionFile }); await this.teardownCurrent("fork", sessionManager.getSessionFile()); this.apply( - await this.createRuntime({ - cwd: this.cwd, - agentDir: this.services.agentDir, - sessionManager, - sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile }, - sessionConfig: this.sessionConfig, - }), + await this.scopedBuild(() => + this.createRuntime({ + cwd: this.cwd, + agentDir: this.services.agentDir, + sessionManager, + sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile }, + sessionConfig: this.sessionConfig, + }), + ), ); await this.finishSessionReplacement(options?.withSession); return { cancelled: false, selectedText }; @@ -458,13 +480,15 @@ export class AgentSessionRuntime implements SubagentRuntimeHost { const sessionManager = SessionManager.open(forkedSessionPath, sessionDir); await this.teardownCurrent("fork", sessionManager.getSessionFile()); this.apply( - await this.createRuntime({ - cwd: sessionManager.getCwd(), - agentDir: this.services.agentDir, - sessionManager, - sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile }, - sessionConfig: this.sessionConfig, - }), + await this.scopedBuild(() => + this.createRuntime({ + cwd: sessionManager.getCwd(), + agentDir: this.services.agentDir, + sessionManager, + sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile }, + sessionConfig: this.sessionConfig, + }), + ), ); await this.finishSessionReplacement(options?.withSession); return { cancelled: false, selectedText }; @@ -478,13 +502,15 @@ export class AgentSessionRuntime implements SubagentRuntimeHost { } await this.teardownCurrent("fork", sessionManager.getSessionFile()); this.apply( - await this.createRuntime({ - cwd: this.cwd, - agentDir: this.services.agentDir, - sessionManager, - sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile }, - sessionConfig: this.sessionConfig, - }), + await this.scopedBuild(() => + this.createRuntime({ + cwd: this.cwd, + agentDir: this.services.agentDir, + sessionManager, + sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile }, + sessionConfig: this.sessionConfig, + }), + ), ); await this.finishSessionReplacement(options?.withSession); return { cancelled: false, selectedText }; @@ -523,13 +549,15 @@ export class AgentSessionRuntime implements SubagentRuntimeHost { assertSessionCwdExists(sessionManager, this.cwd); await this.teardownCurrent("resume", sessionManager.getSessionFile()); this.apply( - await this.createRuntime({ - cwd: sessionManager.getCwd(), - agentDir: this.services.agentDir, - sessionManager, - sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile }, - sessionConfig: this.sessionConfig, - }), + await this.scopedBuild(() => + this.createRuntime({ + cwd: sessionManager.getCwd(), + agentDir: this.services.agentDir, + sessionManager, + sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile }, + sessionConfig: this.sessionConfig, + }), + ), ); await this.finishSessionReplacement(); return { cancelled: false }; diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 2ebb6b444e..18f72d9899 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -596,6 +596,7 @@ export class AgentSession { // Extension system private _extensionRunner!: ExtensionRunner; + private _execEnvProvider?: () => Record | undefined; private _turnIndex = 0; private _resourceLoader: ResourceLoader; @@ -4213,6 +4214,17 @@ export class AgentSession { return this.settingsManager.getCompactionEnabled(); } + /** + * Set the provider for extra env vars merged over process.env in extension + * pi.exec() subprocesses. The function is read at exec time, so a host (e.g. + * the daemon) can update the underlying value per attach without rebinding. + */ + setExecEnvProvider(provider: (() => Record | undefined) | undefined): void { + this._execEnvProvider = provider; + const extensions = this._resourceLoader.getExtensions(); + extensions.runtime.getExecEnv = provider; + } + async bindExtensions(bindings: ExtensionBindings): Promise { if (bindings.uiContext !== undefined) { this._extensionUIContext = bindings.uiContext; @@ -4561,6 +4573,12 @@ export class AgentSession { extensionsResult.runtime.flagValues.set(name, value); } } + // Re-apply on (re)build so the provider survives /reload. Guarded: the + // runtime object can be shared across sessions from one ResourceLoader + // (RLM children), so a provider-less session must not wipe the owner's. + if (this._execEnvProvider) { + extensionsResult.runtime.getExecEnv = this._execEnvProvider; + } this._extensionRunner = new ExtensionRunner( extensionsResult.extensions, diff --git a/packages/coding-agent/src/core/exec.ts b/packages/coding-agent/src/core/exec.ts index 40f0453df7..a96ded7b20 100644 --- a/packages/coding-agent/src/core/exec.ts +++ b/packages/coding-agent/src/core/exec.ts @@ -15,6 +15,11 @@ export interface ExecOptions { timeout?: number; /** Working directory */ cwd?: string; + /** + * Extra env vars merged over the parent process env for this command. + * A key with an undefined value is unset in the child. + */ + env?: Record; } /** @@ -27,6 +32,21 @@ export interface ExecResult { killed: boolean; } +function mergeExecEnv(env?: Record): NodeJS.ProcessEnv | undefined { + if (!env) { + return undefined; + } + const merged: NodeJS.ProcessEnv = { ...process.env }; + for (const [key, value] of Object.entries(env)) { + if (value === undefined) { + delete merged[key]; + } else { + merged[key] = value; + } + } + return merged; +} + /** * Execute a shell command and return stdout/stderr/code. * Supports timeout and abort signal. @@ -42,6 +62,9 @@ export async function execCommand( cwd, shell: false, stdio: ["ignore", "pipe", "pipe"], + // Merge per-call env over the parent env so callers can scope vars + // (e.g. herdr pane identity) without mutating the shared process.env. + env: mergeExecEnv(options?.env), }); let stdout = ""; diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index 4f107bac85..d974d5c79d 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -265,7 +265,11 @@ function createExtensionAPI( exec(command: string, args: string[], options?: ExecOptions) { runtime.assertActive(); - return execCommand(command, args, options?.cwd ?? cwd, options); + // Read the host-supplied env at call time so per-session vars (e.g. + // herdr pane identity) are current, then let an explicit options.env win. + const sessionEnv = runtime.getExecEnv?.(); + const env = sessionEnv || options?.env ? { ...sessionEnv, ...options?.env } : undefined; + return execCommand(command, args, options?.cwd ?? cwd, { ...options, env }); }, getActiveTools(): string[] { diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 81d6c34f4a..f1c2dff968 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -1376,6 +1376,14 @@ export type SetLabelHandler = (entryId: string, label: string | undefined) => vo */ export interface ExtensionRuntimeState { flagValues: Map; + /** + * Extra env vars merged over process.env for pi.exec() subprocesses (an + * undefined value unsets the key in the child). Read at call time (not load + * time) so a host can scope per-session vars — e.g. the daemon supplying + * each session's herdr pane identity — without mutating the shared + * process.env. Returns undefined to use the parent env unchanged. + */ + getExecEnv?: () => Record | undefined; /** Provider registrations queued during extension loading, processed when runner binds */ pendingProviderRegistrations: Array<{ name: string; config: ProviderConfig; extensionPath: string }>; /** Throws when this extension instance is stale after runtime replacement. */ diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 83689093cf..6b5c401128 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -54,6 +54,7 @@ import { SessionManager } from "./core/session-manager.js"; import { SettingsManager } from "./core/settings-manager.js"; import { printTimings, resetTimings, time } from "./core/timings.js"; import { runMigrations, showDeprecationWarnings } from "./migrations.js"; +import { collectDaemonClientEnv } from "./modes/daemon/daemon-protocol.js"; import { type AgentConnection, createInteractiveModeLocalSessionHost, @@ -950,6 +951,7 @@ async function createDaemonInteractiveConnection(options: { const attach = async (summary: SessionSummary) => { const connection = await DaemonAgentConnection.attach(client, getDaemonSummaryActiveSessionId(summary), { closeClientOnDispose: true, + sendClientEnv: true, }); return { connection, summary }; }; @@ -974,6 +976,7 @@ async function createDaemonInteractiveConnection(options: { config: options.config, sessionPath: options.sessionPath, continueRecent: options.continueRecent, + env: collectDaemonClientEnv(), }); if (!response.success) { throw new Error(response.error); diff --git a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts index 2dd51d1917..0ee9cb7a9f 100644 --- a/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts +++ b/packages/coding-agent/src/modes/agent-connection/daemon-agent-connection.ts @@ -10,6 +10,7 @@ import type { SessionStats } from "../../core/session-stats.js"; import type { DaemonClient } from "../daemon/daemon-client.js"; import { deserializeDaemonError } from "../daemon/daemon-errors.js"; import { + collectDaemonClientEnv, type DaemonAttachResult, type DaemonCommand, type DaemonDeleteSavedSessionResult, @@ -59,6 +60,13 @@ export const DAEMON_REFINE_REQUEST_TIMEOUT_MS = 10 * 60 * 1000; export interface DaemonAgentConnectionOptions { closeClientOnDispose?: boolean; + /** + * Send this client's allowlisted env (herdr pane identity) with attach so + * an env-less session (e.g. cron-created) adopts it. Set only by the + * primary interactive connection — the daemon adopts-if-absent, never + * rebinds, so watchers must not send env at all. + */ + sendClientEnv?: boolean; } /** @@ -111,6 +119,7 @@ export class DaemonAgentConnection implements AgentConnection { supportsExtensionUi: true, clientId: this.clientId, capabilities: ["attach_snapshot", "event_sequence", "extension_ui", "slim_attach"], + env: this.options.sendClientEnv ? collectDaemonClientEnv() : undefined, resumeCursor: this.lastEventSequence === undefined ? undefined diff --git a/packages/coding-agent/src/modes/daemon/active-session-state.ts b/packages/coding-agent/src/modes/daemon/active-session-state.ts index 6f396cc5ba..b55ad1900b 100644 --- a/packages/coding-agent/src/modes/daemon/active-session-state.ts +++ b/packages/coding-agent/src/modes/daemon/active-session-state.ts @@ -23,6 +23,14 @@ export interface ActiveSessionState { unsubscribe?: () => void; /** Latest background status summary, surfaced in the agents view. */ summaryState?: AgentStatus; + /** + * Client env (e.g. herdr pane identity), merged over process.env for this + * session's pi.exec() subprocesses. Bound when the runtime is created (or + * adopted from the first env-carrying create that reuses an env-less + * session); never overwritten after that — watchers also attach, and + * extensions capture identity at load. Subagents inherit the parent's. + */ + clientEnv?: Record; } export interface ActiveSessionExtensionUiRequest { diff --git a/packages/coding-agent/src/modes/daemon/daemon-client-env.ts b/packages/coding-agent/src/modes/daemon/daemon-client-env.ts new file mode 100644 index 0000000000..857a4d123e --- /dev/null +++ b/packages/coding-agent/src/modes/daemon/daemon-client-env.ts @@ -0,0 +1,97 @@ +import { DAEMON_CLIENT_ENV_KEYS } from "./daemon-protocol.js"; + +/** Re-filter client-sent env to the allowlist; the socket peer is untrusted. */ +export function filterClientEnv(env?: Record): Record | undefined { + if (!env) { + return undefined; + } + const filtered: Record = {}; + for (const key of DAEMON_CLIENT_ENV_KEYS) { + if (env[key] !== undefined) { + filtered[key] = env[key]; + } + } + return Object.keys(filtered).length > 0 ? filtered : undefined; +} + +// The daemon's own allowlisted env, captured at startup before any env window +// can mutate process.env. +const baseClientEnv: Record = {}; +for (const key of DAEMON_CLIENT_ENV_KEYS) { + baseClientEnv[key] = process.env[key]; +} + +/** + * Exec env for a session's subprocesses: pins every allowlisted key to the + * session's value (unset when the client didn't send it), or to the daemon's + * startup value for env-less sessions. Pinning makes subprocess env + * independent of any env window another session has open at spawn time. + */ +export function execEnvForSession(clientEnv?: Record): Record { + const source = clientEnv ?? baseClientEnv; + const env: Record = {}; + for (const key of DAEMON_CLIENT_ENV_KEYS) { + env[key] = source[key]; + } + return env; +} + +// Shared/exclusive lock: env windows are exclusive (they mutate process.env), +// env-less loads are shared — they run concurrently with each other but never +// inside an env window, so they can't capture another session's identity. +let lastExclusive: Promise = Promise.resolve(); +const activeShared = new Set>(); + +/** + * Run fn with the client's env applied to process.env, restoring afterwards. + * Extensions capture vars like HERDR_PANE_ID synchronously at module load, so + * they must be in process.env while the session loads its extensions; after + * this window the session's exec env covers subprocess reads. + */ +export async function withClientEnv(env: Record | undefined, fn: () => Promise): Promise { + if (!env) { + const gate = lastExclusive; + const run = (async () => { + await gate.catch(() => undefined); + return fn(); + })(); + const tracked = run.catch(() => undefined); + activeShared.add(tracked); + void tracked.then(() => activeShared.delete(tracked)); + return run; + } + const prior = lastExclusive; + // Snapshot synchronously: shareds arriving later gate on this window via + // lastExclusive, so waiting for them here would deadlock. + const sharedAtRequest = [...activeShared]; + const run = (async () => { + await prior.catch(() => undefined); + await Promise.all(sharedAtRequest); + const previous = new Map(); + // Pin the full allowlist (unsetting keys the client didn't send) so a + // partially-forwarded env can't mix with the daemon's ambient values — + // mirroring execEnvForSession. + for (const key of DAEMON_CLIENT_ENV_KEYS) { + previous.set(key, process.env[key]); + const value = env[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + try { + return await fn(); + } finally { + for (const [key, value] of previous) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } + })(); + lastExclusive = run.catch(() => undefined); + return run; +} diff --git a/packages/coding-agent/src/modes/daemon/daemon-extension-binding.ts b/packages/coding-agent/src/modes/daemon/daemon-extension-binding.ts index 9dd2835769..f2e99eada5 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-extension-binding.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-extension-binding.ts @@ -10,6 +10,7 @@ import type { SubagentRuntimeHost } from "../../core/rlm-runtime.js"; import { createAgentConnectionState } from "../agent-connection/snapshot.js"; import { type Theme, theme } from "../interactive/theme/theme.js"; import type { ActiveSessionState } from "./active-session-state.js"; +import { execEnvForSession, withClientEnv } from "./daemon-client-env.js"; import { type DaemonExtensionUIResponse, type DaemonOutbound, @@ -48,6 +49,11 @@ export async function bindActiveSessionState( ): Promise { const session = state.runtime.session; + session.setExecEnvProvider(() => execEnvForSession(state.clientEnv)); + // Every runtime rebuild (new/switch/fork/import, subagent spawn) re-loads + // extensions, which capture client env synchronously at that moment. + state.runtime.setRuntimeEnvScope((fn) => withClientEnv(state.clientEnv, fn)); + state.unsubscribe?.(); state.runtime.setSubagentRuntimeHost(callbacks.subagentRuntimeHost); state.unsubscribe = session.subscribe((event) => { @@ -103,7 +109,9 @@ function createCommandContextActions(state: ActiveSessionState): ExtensionComman }, switchSession: async (sessionPath, options) => state.runtime.switchSession(sessionPath, options), reload: async () => { - await state.runtime.session.reload(); + // Reload re-evaluates extension modules, which capture client env + // (e.g. herdr pane identity) synchronously at load. + await withClientEnv(state.clientEnv, () => state.runtime.session.reload()); }, }; } diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index a2461b1a52..9d98837a8b 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -84,6 +84,7 @@ import { type DaemonSocketClient, resolveActiveSessionState, } from "./active-session-state.js"; +import { filterClientEnv, withClientEnv } from "./daemon-client-env.js"; import { serializeDaemonError } from "./daemon-errors.js"; import { bindActiveSessionState } from "./daemon-extension-binding.js"; import { @@ -344,9 +345,28 @@ export class AgentDaemon { cleanupDaemonSocketPath(this.socketPath); } + /** + * Adopt env for a session that has none, propagating to subagents spawned + * before adoption (their exec-env providers read state.clientEnv live). + * Never overwrites an existing identity. + */ + private adoptClientEnv(state: ActiveSessionState, env?: Record): void { + if (!env || state.clientEnv) { + return; + } + state.clientEnv = env; + for (const child of this.sessions.values()) { + const metadata = child.runtime.metadata; + if (metadata.kind === "subagent" && metadata.parentActiveSessionId === state.activeSessionId) { + this.adoptClientEnv(child, env); + } + } + } + private async addRuntime( runtime: AgentSessionRuntime, name?: string, + clientEnv?: Record, onStateCreated?: (state: ActiveSessionState) => void, ): Promise { const state: ActiveSessionState = { @@ -355,6 +375,7 @@ export class AgentDaemon { clients: new Set(), extensionUiRequests: new Map(), lastEventSequence: 0, + clientEnv, }; if (name) { state.runtime.session.setSessionName(name); @@ -405,6 +426,7 @@ export class AgentDaemon { const cwd = resolve(config.cwd); const agentDir = config.agentDir; + const clientEnv = filterClientEnv(command.env); const cwdOverride = command.config?.cwd ? resolve(command.config.cwd) : undefined; const sessionPath = command.sessionPath ? await resolveDaemonSessionPath(command.sessionPath, cwd, config.sessionDir) @@ -419,79 +441,89 @@ export class AgentDaemon { if (existing) { // A live runtime already owns this session file; reuse it instead of // starting a second runtime that would interleave writes to one file. + // clientEnv adopts the first offered identity (e.g. a pane opening a + // cron-created session) but never overwrites one: extensions captured + // the creator's identity at load, and swapping it would only make + // pi.exec disagree with those captures. if (command.name) { existing.runtime.session.setSessionName(command.name); } + this.adoptClientEnv(existing, clientEnv); this.rebindCronJobsToState(existing); return existing; } let stateRef: ActiveSessionState | undefined; - const runtime = await createAgentSessionRuntime(this.options.createRuntime, { - cwd: sessionManager.getCwd(), - agentDir, - sessionManager, - sessionConfig: config, - sessionOptions: { - customTools: [ - ...createAgentHeartbeatToolDefinitions({ - getHeartbeat: () => { + // Extensions capture client env (e.g. herdr pane identity) synchronously + // while the runtime loads them, so it must be in process.env for the + // duration; withClientEnv restores it after. + const runtime = await withClientEnv(clientEnv, () => + createAgentSessionRuntime(this.options.createRuntime, { + cwd: sessionManager.getCwd(), + agentDir, + sessionManager, + sessionConfig: config, + sessionOptions: { + customTools: [ + ...createAgentHeartbeatToolDefinitions({ + getHeartbeat: () => { + if (!stateRef) { + throw new Error("Heartbeat state is not ready for this session yet"); + } + return this.cronStore.getHeartbeat(stateRef.activeSessionId); + }, + }), + ], + rlmHeartbeatController: { + listRlmHeartbeats: (listOptions) => { if (!stateRef) { - throw new Error("Heartbeat state is not ready for this session yet"); + throw new Error("RLM heartbeat state is not ready for this session yet"); } - return this.cronStore.getHeartbeat(stateRef.activeSessionId); + return this.cronStore.listRlmHeartbeats(stateRef.activeSessionId, listOptions); + }, + createRlmHeartbeat: (input) => { + if (!stateRef) { + throw new Error("RLM heartbeat state is not ready for this session yet"); + } + return this.createRlmHeartbeatForState(stateRef, input); + }, + updateRlmHeartbeat: (input) => { + if (!stateRef) { + throw new Error("RLM heartbeat state is not ready for this session yet"); + } + return this.updateRlmHeartbeatForState(stateRef, input); + }, + deleteRlmHeartbeat: (id) => { + if (!stateRef) { + throw new Error("RLM heartbeat state is not ready for this session yet"); + } + return this.deleteRlmHeartbeatForState(stateRef, id); }, - }), - ], - rlmHeartbeatController: { - listRlmHeartbeats: (listOptions) => { - if (!stateRef) { - throw new Error("RLM heartbeat state is not ready for this session yet"); - } - return this.cronStore.listRlmHeartbeats(stateRef.activeSessionId, listOptions); - }, - createRlmHeartbeat: (input) => { - if (!stateRef) { - throw new Error("RLM heartbeat state is not ready for this session yet"); - } - return this.createRlmHeartbeatForState(stateRef, input); - }, - updateRlmHeartbeat: (input) => { - if (!stateRef) { - throw new Error("RLM heartbeat state is not ready for this session yet"); - } - return this.updateRlmHeartbeatForState(stateRef, input); - }, - deleteRlmHeartbeat: (id) => { - if (!stateRef) { - throw new Error("RLM heartbeat state is not ready for this session yet"); - } - return this.deleteRlmHeartbeatForState(stateRef, id); - }, - }, - agentMessageController: { - listAgents: () => { - if (!stateRef) { - throw new Error("Agent message state is not ready for this session yet"); - } - return this.createAgentMessageListResult(stateRef); }, - sendAgentMessage: (input) => { - if (!stateRef) { - throw new Error("Agent message state is not ready for this session yet"); - } - return this.sendAgentSessionMessage({ - targetSelector: input.target, - message: input.message, - fromState: stateRef, - deliveryMode: input.deliveryMode, - origin: "agent", - }); + agentMessageController: { + listAgents: () => { + if (!stateRef) { + throw new Error("Agent message state is not ready for this session yet"); + } + return this.createAgentMessageListResult(stateRef); + }, + sendAgentMessage: (input) => { + if (!stateRef) { + throw new Error("Agent message state is not ready for this session yet"); + } + return this.sendAgentSessionMessage({ + targetSelector: input.target, + message: input.message, + fromState: stateRef, + deliveryMode: input.deliveryMode, + origin: "agent", + }); + }, }, + agentObserveController: this.createAgentObserveController(() => stateRef), }, - agentObserveController: this.createAgentObserveController(() => stateRef), - }, - }); - return this.addRuntime(runtime, command.name, (state) => { + }), + ); + return this.addRuntime(runtime, command.name, clientEnv, (state) => { stateRef = state; }); }; @@ -503,10 +535,13 @@ export class AgentDaemon { const sessionKey = resolve(sessionFile); const pending = this.openingSessions.get(sessionKey); if (pending) { + // Same as the reuse path above: adopt-if-absent, never overwrite the + // identity the racing creator's extensions already captured. const state = await pending; if (command.name) { state.runtime.session.setSessionName(command.name); } + this.adoptClientEnv(state, clientEnv); this.rebindCronJobsToState(state); return state; } @@ -846,65 +881,68 @@ export class AgentDaemon { sessionManager.newSession({ parentSession: options.parentSession.sessionFile }); } let stateRef: ActiveSessionState | undefined; - const runtime = await createAgentSessionRuntime(this.options.createRuntime, { - cwd: sessionManager.getCwd(), - agentDir: parentState.runtime.services.agentDir, - sessionManager, - sessionStartEvent: { type: "session_start", reason: "startup" }, - sessionConfig: parentState.runtime.runtimeConfig, - sessionOptions: { - model: options.model, - thinkingLevel: options.thinkingLevel, - scopedModels: options.scopedModels, - initialActiveToolNames: options.activeToolNames, - allowedToolNames: options.allowedToolNames, - customTools: options.customTools, - includeGoals: options.includeGoals, - rlmHeartbeatController: { - listRlmHeartbeats: (listOptions) => { - if (!stateRef) { - throw new Error("RLM heartbeat state is not ready for this session yet"); - } - return this.cronStore.listRlmHeartbeats(stateRef.activeSessionId, listOptions); - }, - createRlmHeartbeat: (input) => { - if (!stateRef) { - throw new Error("RLM heartbeat state is not ready for this session yet"); - } - return this.createRlmHeartbeatForState(stateRef, input); - }, - updateRlmHeartbeat: (input) => { - if (!stateRef) { - throw new Error("RLM heartbeat state is not ready for this session yet"); - } - return this.updateRlmHeartbeatForState(stateRef, input); - }, - deleteRlmHeartbeat: (id) => { - if (!stateRef) { - throw new Error("RLM heartbeat state is not ready for this session yet"); - } - return this.deleteRlmHeartbeatForState(stateRef, id); + // Subagents inherit the parent's client env (e.g. herdr pane identity). + const runtime = await withClientEnv(parentState.clientEnv, () => + createAgentSessionRuntime(this.options.createRuntime, { + cwd: sessionManager.getCwd(), + agentDir: parentState.runtime.services.agentDir, + sessionManager, + sessionStartEvent: { type: "session_start", reason: "startup" }, + sessionConfig: parentState.runtime.runtimeConfig, + sessionOptions: { + model: options.model, + thinkingLevel: options.thinkingLevel, + scopedModels: options.scopedModels, + initialActiveToolNames: options.activeToolNames, + allowedToolNames: options.allowedToolNames, + customTools: options.customTools, + includeGoals: options.includeGoals, + rlmHeartbeatController: { + listRlmHeartbeats: (listOptions) => { + if (!stateRef) { + throw new Error("RLM heartbeat state is not ready for this session yet"); + } + return this.cronStore.listRlmHeartbeats(stateRef.activeSessionId, listOptions); + }, + createRlmHeartbeat: (input) => { + if (!stateRef) { + throw new Error("RLM heartbeat state is not ready for this session yet"); + } + return this.createRlmHeartbeatForState(stateRef, input); + }, + updateRlmHeartbeat: (input) => { + if (!stateRef) { + throw new Error("RLM heartbeat state is not ready for this session yet"); + } + return this.updateRlmHeartbeatForState(stateRef, input); + }, + deleteRlmHeartbeat: (id) => { + if (!stateRef) { + throw new Error("RLM heartbeat state is not ready for this session yet"); + } + return this.deleteRlmHeartbeatForState(stateRef, id); + }, }, + rlmDepth: options.rlmDepth, + rlmMaxDepth: options.rlmMaxDepth, + rlmSessionDir: options.sessionDir, + rlmParentNodeId: options.rlmParentNodeId, }, - rlmDepth: options.rlmDepth, - rlmMaxDepth: options.rlmMaxDepth, - rlmSessionDir: options.sessionDir, - rlmParentNodeId: options.rlmParentNodeId, - }, - runtimeMetadata: { - kind: "subagent", - createdAt: Date.now(), - parentActiveSessionId: parentState.activeSessionId, - parentSessionId: options.parentSession.sessionId, - parentSessionFile: options.parentSession.sessionFile, - rlmChildId: options.id, - rlmParentNodeId: options.rlmParentNodeId, - prompt: options.prompt, - spawnCode: options.spawnCode, - sessionDir: options.sessionDir, - }, - }); - await this.addRuntime(runtime, undefined, (state) => { + runtimeMetadata: { + kind: "subagent", + createdAt: Date.now(), + parentActiveSessionId: parentState.activeSessionId, + parentSessionId: options.parentSession.sessionId, + parentSessionFile: options.parentSession.sessionFile, + rlmChildId: options.id, + rlmParentNodeId: options.rlmParentNodeId, + prompt: options.prompt, + spawnCode: options.spawnCode, + sessionDir: options.sessionDir, + }, + }), + ); + await this.addRuntime(runtime, undefined, parentState.clientEnv, (state) => { stateRef = state; }); return runtime; @@ -1144,6 +1182,7 @@ export class AgentDaemon { } client.capabilities = normalizeClientCapabilities(command.capabilities, command.supportsExtensionUi); client.supportsExtensionUi = client.capabilities.has("extension_ui"); + this.adoptClientEnv(state, filterClientEnv(command.env)); state.clients.add(client); client.attachedActiveSessionIds.add(state.activeSessionId); const result = this.createAttachResult(client, state, command); @@ -1559,7 +1598,9 @@ export class AgentDaemon { case "reload": { const state = this.getSessionState(command.activeSessionId); - await state.runtime.session.reload(); + // Reload re-evaluates extension modules, which capture client env + // (e.g. herdr pane identity) synchronously at load. + await withClientEnv(state.clientEnv, () => state.runtime.session.reload()); return success(command.id, "reload"); } diff --git a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts index fcdb9bc589..febfe15887 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts @@ -70,6 +70,43 @@ export interface DaemonAttachClientMetadata { resumeCursor?: DaemonResumeCursor; } +/** + * Client-side env vars forwarded to the daemon so extensions can reach them + * (e.g. HERDR_PANE_ID/HERDR_SOCKET_PATH that herdr sets per pane). The daemon + * scopes these to the created session and merges them over process.env for + * that session's pi.exec() subprocesses — it does not mutate the daemon's own + * env. Carried on create only: attach must not rebind a session's identity, + * since watchers (agents view, subagent viewers) also attach. + */ +export interface DaemonClientEnv { + env?: Record; +} + +/** + * The allowlist of env vars a client may forward. One shared list because it + * is the wire contract: clients filter before sending and the daemon + * re-filters on receipt (the socket peer is untrusted). + */ +export const DAEMON_CLIENT_ENV_KEYS = [ + "HERDR_ENV", + "HERDR_PANE_ID", + "HERDR_SOCKET_PATH", + "HERDR_TAB_ID", + "HERDR_WORKSPACE_ID", +] as const; + +/** Collect the allowlisted env vars from the client process for the create command. */ +export function collectDaemonClientEnv(source: NodeJS.ProcessEnv = process.env): Record | undefined { + const env: Record = {}; + for (const key of DAEMON_CLIENT_ENV_KEYS) { + const value = source[key]; + if (value !== undefined) { + env[key] = value; + } + } + return Object.keys(env).length > 0 ? env : undefined; +} + export interface DaemonReplayInfo { status: DaemonReplayStatus; fromSequence?: DaemonEventSequence; @@ -151,20 +188,24 @@ export interface DaemonAttachResult { export type DaemonCommand = | { id?: string; type: "list"; all?: boolean; cwd?: string; sessionDir?: string } | { id?: string; type: "list_saved_sessions"; activeSessionId: string; scope: AgentConnectionSavedSessionScope } - | { + | ({ id?: string; type: "create"; sessionPath?: string; continueRecent?: boolean; name?: string; config?: AgentSessionRuntimeConfig; - } + } & DaemonClientEnv) + // Attach env is adopt-if-absent only: it fills identity for env-less + // sessions (e.g. cron-created) but never rebinds one, since watchers + // (agents view, subagent viewers) also attach. | ({ id?: string; type: "attach"; activeSessionId: string; supportsExtensionUi?: boolean; - } & DaemonAttachClientMetadata) + } & DaemonAttachClientMetadata & + DaemonClientEnv) | { id?: string; type: "detach"; activeSessionId?: string } | { id?: string; type: "kill"; activeSessionId: string } | { id?: string; type: "rename"; activeSessionId: string; name: string } diff --git a/packages/coding-agent/test/daemon-client-env.test.ts b/packages/coding-agent/test/daemon-client-env.test.ts new file mode 100644 index 0000000000..4e1162a418 --- /dev/null +++ b/packages/coding-agent/test/daemon-client-env.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { execEnvForSession, filterClientEnv, withClientEnv } from "../src/modes/daemon/daemon-client-env.js"; + +describe("filterClientEnv", () => { + it("keeps only allowlisted keys", () => { + expect(filterClientEnv({ HERDR_PANE_ID: "w1:p1", PATH: "/evil", HERDR_ENV: "1" })).toEqual({ + HERDR_PANE_ID: "w1:p1", + HERDR_ENV: "1", + }); + }); + + it("returns undefined for missing or empty env", () => { + expect(filterClientEnv(undefined)).toBeUndefined(); + expect(filterClientEnv({})).toBeUndefined(); + expect(filterClientEnv({ PATH: "/evil" })).toBeUndefined(); + }); +}); + +describe("withClientEnv", () => { + it("applies env during fn, unsetting omitted allowlisted keys, and restores afterwards", async () => { + process.env.HERDR_PANE_ID = "original"; + process.env.HERDR_WORKSPACE_ID = "ambient"; + delete process.env.HERDR_TAB_ID; + let seenPane: string | undefined; + let seenTab: string | undefined; + let seenWorkspace: string | undefined = "unset"; + await withClientEnv({ HERDR_PANE_ID: "w2:p1", HERDR_TAB_ID: "t1" }, async () => { + seenPane = process.env.HERDR_PANE_ID; + seenTab = process.env.HERDR_TAB_ID; + seenWorkspace = process.env.HERDR_WORKSPACE_ID; + }); + expect(seenPane).toBe("w2:p1"); + expect(seenTab).toBe("t1"); + // The daemon's ambient value must not mix into a partial client env. + expect(seenWorkspace).toBeUndefined(); + expect(process.env.HERDR_PANE_ID).toBe("original"); + expect(process.env.HERDR_TAB_ID).toBeUndefined(); + expect(process.env.HERDR_WORKSPACE_ID).toBe("ambient"); + delete process.env.HERDR_PANE_ID; + delete process.env.HERDR_WORKSPACE_ID; + }); + + it("restores even when fn throws", async () => { + process.env.HERDR_PANE_ID = "original"; + await expect( + withClientEnv({ HERDR_PANE_ID: "w2:p1" }, async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + expect(process.env.HERDR_PANE_ID).toBe("original"); + delete process.env.HERDR_PANE_ID; + }); + + it("serializes overlapping windows so envs never mix", async () => { + delete process.env.HERDR_PANE_ID; + const seen: Array = []; + const slow = withClientEnv({ HERDR_PANE_ID: "a" }, async () => { + await new Promise((r) => setTimeout(r, 20)); + seen.push(process.env.HERDR_PANE_ID); + }); + const fast = withClientEnv({ HERDR_PANE_ID: "b" }, async () => { + seen.push(process.env.HERDR_PANE_ID); + }); + await Promise.all([slow, fast]); + expect(seen).toEqual(["a", "b"]); + expect(process.env.HERDR_PANE_ID).toBeUndefined(); + }); + + it("runs fn directly without env", async () => { + let ran = false; + await withClientEnv(undefined, async () => { + ran = true; + }); + expect(ran).toBe(true); + }); + + it("env-less loads never run inside an env window", async () => { + delete process.env.HERDR_PANE_ID; + let seenByEnvless: string | undefined = "unset"; + const windowed = withClientEnv({ HERDR_PANE_ID: "a" }, async () => { + await new Promise((r) => setTimeout(r, 20)); + }); + const envless = withClientEnv(undefined, async () => { + seenByEnvless = process.env.HERDR_PANE_ID; + }); + await Promise.all([windowed, envless]); + expect(seenByEnvless).toBeUndefined(); + }); + + it("execEnvForSession pins keys independent of active env windows", async () => { + const baseline = execEnvForSession(); + await withClientEnv({ HERDR_PANE_ID: "window-only" }, async () => { + // An env-less session's exec env is the daemon's startup base, not + // whatever another session's window put in process.env. + expect(execEnvForSession()).toStrictEqual(baseline); + // A session with env gets exactly its values; missing keys are unset. + expect(execEnvForSession({ HERDR_PANE_ID: "w9:p9" })).toStrictEqual({ + HERDR_ENV: undefined, + HERDR_PANE_ID: "w9:p9", + HERDR_SOCKET_PATH: undefined, + HERDR_TAB_ID: undefined, + HERDR_WORKSPACE_ID: undefined, + }); + }); + }); + + it("env windows wait for in-flight env-less loads", async () => { + delete process.env.HERDR_PANE_ID; + const order: string[] = []; + const envless = withClientEnv(undefined, async () => { + await new Promise((r) => setTimeout(r, 20)); + order.push(`envless:${process.env.HERDR_PANE_ID}`); + }); + const windowed = withClientEnv({ HERDR_PANE_ID: "b" }, async () => { + order.push(`windowed:${process.env.HERDR_PANE_ID}`); + }); + await Promise.all([envless, windowed]); + expect(order).toEqual(["envless:undefined", "windowed:b"]); + expect(process.env.HERDR_PANE_ID).toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index 2839812c48..e620f20a3b 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -2163,6 +2163,53 @@ describe("daemon mode helpers", () => { } }); + it("adopts client env on session reuse only when the session has none", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-env-")); + try { + const sessionPath = join(tempDir, "session.jsonl"); + const createRuntime = vi.fn(async (options: Parameters[0]) => { + return { + session: makeRuntimeSession(options.sessionManager), + extensionsResult: { extensions: [], errors: [], runtime: {} } as unknown as Awaited< + ReturnType + >["extensionsResult"], + services: { cwd: options.cwd, agentDir: options.agentDir } as Awaited< + ReturnType + >["services"], + diagnostics: [], + }; + }); + const daemon = new AgentDaemon(join(tempDir, "daemon.sock"), { + defaultSessionConfig: { agentDir: tempDir, cwd: tempDir, sessionDir: tempDir }, + createRuntime, + }); + const create = ( + daemon as unknown as { + createRuntime(command: Extract): Promise; + } + ).createRuntime.bind(daemon); + + // Created env-less (e.g. by a cron job), then opened by an env-carrying + // client: the session adopts the client's allowlisted identity. + const state = await create({ type: "create", sessionPath }); + expect(state.clientEnv).toBeUndefined(); + const adopted = await create({ + type: "create", + sessionPath, + env: { HERDR_PANE_ID: "w1:p1", PATH: "/evil" }, + }); + expect(adopted).toBe(state); + expect(state.clientEnv).toEqual({ HERDR_PANE_ID: "w1:p1" }); + + // A later client with a different env must not rebind the identity + // that extensions already captured. + await create({ type: "create", sessionPath, env: { HERDR_PANE_ID: "w2:p9" } }); + expect(state.clientEnv).toEqual({ HERDR_PANE_ID: "w1:p1" }); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("makes daemon host controllers available during session_start extension binding", async () => { const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-controller-race-")); try { @@ -2897,6 +2944,7 @@ function makeRuntimeSession( setSubagentRuntimeHost: vi.fn(), subscribe: vi.fn(() => vi.fn()), bindExtensions: vi.fn(async () => {}), + setExecEnvProvider: vi.fn(), setSessionName: vi.fn(), dispose: vi.fn(), abort: vi.fn(async () => {}),