Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,7 @@ export class AgentSession {

// Extension system
private _extensionRunner!: ExtensionRunner;
private _execEnvProvider?: () => Record<string, string> | undefined;
private _turnIndex = 0;

private _resourceLoader: ResourceLoader;
Expand Down Expand Up @@ -3193,6 +3194,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<string, string> | undefined) | undefined): void {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
this._execEnvProvider = provider;
const extensions = this._resourceLoader.getExtensions();
extensions.runtime.getExecEnv = provider;
}

async bindExtensions(bindings: ExtensionBindings): Promise<void> {
if (bindings.uiContext !== undefined) {
this._extensionUIContext = bindings.uiContext;
Expand Down Expand Up @@ -3541,6 +3553,9 @@ export class AgentSession {
extensionsResult.runtime.flagValues.set(name, value);
}
}
// Re-apply on every (re)build so the provider survives /reload, which
// recreates the shared extension runtime.
extensionsResult.runtime.getExecEnv = this._execEnvProvider;

this._extensionRunner = new ExtensionRunner(
extensionsResult.extensions,
Expand Down
5 changes: 5 additions & 0 deletions packages/coding-agent/src/core/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface ExecOptions {
timeout?: number;
/** Working directory */
cwd?: string;
/** Extra env vars merged over the parent process env for this command. */
env?: Record<string, string>;
}

/**
Expand Down Expand Up @@ -42,6 +44,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: options?.env ? { ...process.env, ...options.env } : undefined,
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
});

let stdout = "";
Expand Down
6 changes: 5 additions & 1 deletion packages/coding-agent/src/core/extensions/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down
7 changes: 7 additions & 0 deletions packages/coding-agent/src/core/extensions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1376,6 +1376,13 @@ export type SetLabelHandler = (entryId: string, label: string | undefined) => vo
*/
export interface ExtensionRuntimeState {
flagValues: Map<string, boolean | string>;
/**
* Extra env vars merged over process.env for pi.exec() subprocesses. 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<string, string> | 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. */
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -972,6 +973,7 @@ async function createDaemonInteractiveConnection(options: {
config: options.config,
sessionPath: options.sessionPath,
continueRecent: options.continueRecent,
env: collectDaemonClientEnv(),
Comment thread
cursor[bot] marked this conversation as resolved.
});
if (!response.success) {
throw new Error(response.error);
Expand Down
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -111,6 +112,7 @@ export class DaemonAgentConnection implements AgentConnection {
supportsExtensionUi: true,
clientId: this.clientId,
capabilities: ["attach_snapshot", "event_sequence", "extension_ui", "slim_attach"],
env: collectDaemonClientEnv(),
resumeCursor:
this.lastEventSequence === undefined
? undefined
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ 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. Last env-carrying create/attach wins.
*/
clientEnv?: Record<string, string>;
}

export interface ActiveSessionExtensionUiRequest {
Expand Down
51 changes: 51 additions & 0 deletions packages/coding-agent/src/modes/daemon/daemon-client-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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<string, string>): Record<string, string> | undefined {
if (!env) {
return undefined;
}
const filtered: Record<string, string> = {};
for (const key of DAEMON_CLIENT_ENV_KEYS) {
if (env[key] !== undefined) {
filtered[key] = env[key];
}
}
return Object.keys(filtered).length > 0 ? filtered : undefined;
}

// Serializes env windows so overlapping creates can't cross-restore each
// other's values. Only env-carrying operations queue here.
let envWindow: Promise<unknown> = Promise.resolve();

/**
* 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<T>(env: Record<string, string> | undefined, fn: () => Promise<T>): Promise<T> {
if (!env) {
return fn();
}
const run = envWindow.then(async () => {
const previous = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(env)) {
previous.set(key, process.env[key]);
process.env[key] = value;
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
try {
return await fn();
} finally {
for (const [key, value] of previous) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
});
envWindow = run.catch(() => undefined);
return run;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 { withClientEnv } from "./daemon-client-env.js";
import {
type DaemonExtensionUIResponse,
type DaemonOutbound,
Expand Down Expand Up @@ -48,6 +49,9 @@ export async function bindActiveSessionState(
): Promise<void> {
const session = state.runtime.session;

// Read live so attach updates to state.clientEnv apply without rebinding.
session.setExecEnvProvider(() => state.clientEnv);

state.unsubscribe?.();
state.runtime.setSubagentRuntimeHost(callbacks.subagentRuntimeHost);
state.unsubscribe = session.subscribe((event) => {
Expand Down Expand Up @@ -103,7 +107,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());
},
};
}
Expand Down
103 changes: 62 additions & 41 deletions packages/coding-agent/src/modes/daemon/daemon-mode.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

The new_session, switch_session, fork, and import_jsonl handlers rebuild the runtime via state.runtime's stored factory, which calls createAgentSessionRuntime() with the unwrapped this.options.createRuntime. Extensions are loaded during that factory call without withClientEnv(state.clientEnv, ...), so state.clientEnv is never applied to process.env and pane-scoped extensions cannot read their client identity (e.g. HERDR_PANE_ID). Only the explicit reload command wraps extension loading in withClientEnv, so any session-replacement flow silently drops client env from extension load context.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/coding-agent/src/modes/daemon/daemon-mode.ts around line 1311:

The `new_session`, `switch_session`, `fork`, and `import_jsonl` handlers rebuild the runtime via `state.runtime`'s stored factory, which calls `createAgentSessionRuntime()` with the unwrapped `this.options.createRuntime`. Extensions are loaded during that factory call without `withClientEnv(state.clientEnv, ...)`, so `state.clientEnv` is never applied to `process.env` and pane-scoped extensions cannot read their client identity (e.g. `HERDR_PANE_ID`). Only the explicit `reload` command wraps extension loading in `withClientEnv`, so any session-replacement flow silently drops client env from extension load context.

Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,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 {
Expand Down Expand Up @@ -293,13 +294,18 @@ export class AgentDaemon {
cleanupDaemonSocketPath(this.socketPath);
}

private async addRuntime(runtime: AgentSessionRuntime, name?: string): Promise<ActiveSessionState> {
private async addRuntime(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
runtime: AgentSessionRuntime,
name?: string,
clientEnv?: Record<string, string>,
): Promise<ActiveSessionState> {
const state: ActiveSessionState = {
activeSessionId: createActiveSessionId(this.sessions),
runtime,
clients: new Set(),
extensionUiRequests: new Map(),
lastEventSequence: 0,
clientEnv,
};
try {
await bindActiveSessionState(state, {
Expand Down Expand Up @@ -335,6 +341,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)
Expand All @@ -352,55 +359,63 @@ export class AgentDaemon {
if (command.name) {
existing.runtime.session.setSessionName(command.name);
}
if (clientEnv) {
existing.clientEnv = clientEnv;
}
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
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);
},
},
},
});
const state = await this.addRuntime(runtime, command.name);
}),
);
const state = await this.addRuntime(runtime, command.name, clientEnv);
stateRef = state;
return state;
};
Expand All @@ -416,6 +431,9 @@ export class AgentDaemon {
if (command.name) {
state.runtime.session.setSessionName(command.name);
}
if (clientEnv) {
state.clientEnv = clientEnv;
}
this.rebindCronJobsToState(state);
return state;
}
Expand Down Expand Up @@ -886,6 +904,9 @@ export class AgentDaemon {
}
client.capabilities = normalizeClientCapabilities(command.capabilities, command.supportsExtensionUi);
client.supportsExtensionUi = client.capabilities.has("extension_ui");
// Most recent env-carrying client wins; env-less attaches (e.g. a
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
// plain REPL) keep the session's existing identity.
state.clientEnv = filterClientEnv(command.env) ?? state.clientEnv;
state.clients.add(client);
client.attachedActiveSessionIds.add(state.activeSessionId);
const result = this.createAttachResult(client, state, command);
Expand Down
Loading