feat(FN-3961): propagate taskEnv into agent subprocess sessions

Propagates `taskEnv` (task-scoped environment variables) through the agent session creation pipeline, including executor sessions, spawned child agents, and workflow sessions. The engine's session factory (`agent-session-helpers.ts`), executor, PI agent creation (`pi.ts`), and step session executor

Fusion-Task-Id: FN-3961
This commit is contained in:
Fusion
2026-05-10 21:02:45 -07:00
committed by gsxdsm
parent 4d2f02906d
commit 86df0a0f2d
10 changed files with 178 additions and 17 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Executor task runtime environment now flows through `createResolvedAgentSession()` and `createFnAgent()` into task-scoped agent subprocesses (including executor-session bash commands). Plugin-provided `executorRuntimeEnv` PATH/env contributions are available inside agent-issued subprocesses while remaining isolated per task/session with no global `process.env` mutation.

View File

@@ -1,10 +1,22 @@
import { describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { import {
extractRuntimeHint, extractRuntimeHint,
resolveHeartbeatSessionModels, resolveHeartbeatSessionModels,
resolveMergerSessionModel, resolveMergerSessionModel,
} from "../agent-session-helpers.js"; } from "../agent-session-helpers.js";
const { resolveRuntimeMock } = vi.hoisted(() => ({
resolveRuntimeMock: vi.fn(),
}));
vi.mock("../runtime-resolution.js", async () => {
const actual = await vi.importActual<typeof import("../runtime-resolution.js")>("../runtime-resolution.js");
return {
...actual,
resolveRuntime: resolveRuntimeMock,
};
});
describe("extractRuntimeHint", () => { describe("extractRuntimeHint", () => {
it("returns undefined for undefined config", () => { it("returns undefined for undefined config", () => {
expect(extractRuntimeHint(undefined)).toBeUndefined(); expect(extractRuntimeHint(undefined)).toBeUndefined();
@@ -74,6 +86,48 @@ describe("resolveHeartbeatSessionModels", () => {
}); });
}); });
describe("createResolvedAgentSession", () => {
beforeEach(() => {
resolveRuntimeMock.mockReset();
});
it("forwards taskEnv unchanged to runtime session factory", async () => {
const mockSession = { prompt: vi.fn() } as any;
const createSessionMock = vi.fn().mockResolvedValue({
session: mockSession,
sessionFile: "session.json",
});
resolveRuntimeMock.mockResolvedValue({
runtime: {
id: "pi",
name: "Default PI Runtime",
createSession: createSessionMock,
promptWithFallback: vi.fn(),
describeModel: vi.fn(() => "mock/model"),
},
runtimeId: "pi",
wasConfigured: false,
});
const { createResolvedAgentSession } = await import("../agent-session-helpers.js");
const taskEnv = { PATH: "/tmp/bin", FUSION_TEST_VAR: "value" };
await createResolvedAgentSession({
sessionPurpose: "executor",
pluginRunner: undefined,
cwd: "/tmp/project",
systemPrompt: "system",
taskEnv,
});
expect(createSessionMock).toHaveBeenCalledWith(
expect.objectContaining({
taskEnv,
}),
);
});
});
describe("resolveMergerSessionModel", () => { describe("resolveMergerSessionModel", () => {
it("uses assigned agent runtime model when both provider and modelId are present", () => { it("uses assigned agent runtime model when both provider and modelId are present", () => {
expect( expect(

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PathLike } from "node:fs"; import type { PathLike } from "node:fs";
const createAgentSessionMock = vi.fn(); const createAgentSessionMock = vi.fn();
const createBashToolMock = vi.fn((cwd: string, options?: any) => ({ name: "bash", cwd, options }));
const createCodingToolsMock = vi.fn(() => []); const createCodingToolsMock = vi.fn(() => []);
const createReadOnlyToolsMock = vi.fn(() => []); const createReadOnlyToolsMock = vi.fn(() => []);
const createExtensionRuntimeMock = vi.fn(); const createExtensionRuntimeMock = vi.fn();
@@ -81,7 +82,7 @@ vi.mock("@mariozechner/pi-coding-agent", () => ({
}), }),
}, },
createAgentSession: createAgentSessionMock, createAgentSession: createAgentSessionMock,
createBashTool: () => ({ name: "bash" }), createBashTool: createBashToolMock,
createCodingTools: createCodingToolsMock, createCodingTools: createCodingToolsMock,
createEditTool: () => ({ name: "edit" }), createEditTool: () => ({ name: "edit" }),
createExtensionRuntime: createExtensionRuntimeMock, createExtensionRuntime: createExtensionRuntimeMock,
@@ -810,6 +811,7 @@ describe("createFnAgent", () => {
readFileSyncMock.mockReturnValue("{}"); readFileSyncMock.mockReturnValue("{}");
readCustomProvidersMock.mockReturnValue([]); readCustomProvidersMock.mockReturnValue([]);
findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId })); findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId }));
createBashToolMock.mockClear();
createAgentSessionMock.mockResolvedValue({ createAgentSessionMock.mockResolvedValue({
session: { session: {
prompt: vi.fn(), prompt: vi.fn(),
@@ -820,6 +822,55 @@ describe("createFnAgent", () => {
}); });
}); });
it("passes task-scoped env into bash spawn hook when provided", async () => {
const { createFnAgent } = await import("../pi.js");
await createFnAgent({
cwd: "/project",
systemPrompt: "test",
tools: "coding",
taskEnv: { PATH: "/task/bin", TASK_ONLY: "1" },
});
expect(createBashToolMock).toHaveBeenCalledWith(
"/project",
expect.objectContaining({
spawnHook: expect.any(Function),
}),
);
const spawnHook = createBashToolMock.mock.calls.at(-1)?.[1]?.spawnHook;
const originalEnv = { PATH: "/base/bin", HOME: "/home/user" };
const spawned = spawnHook({
command: "echo hi",
cwd: "/project",
env: originalEnv,
});
expect(spawned).toEqual({
command: "echo hi",
cwd: "/project",
env: {
PATH: "/task/bin",
HOME: "/home/user",
TASK_ONLY: "1",
},
});
expect(originalEnv).toEqual({ PATH: "/base/bin", HOME: "/home/user" });
});
it("keeps bash tool default behavior when taskEnv is not provided", async () => {
const { createFnAgent } = await import("../pi.js");
await createFnAgent({
cwd: "/project",
systemPrompt: "test",
tools: "coding",
});
expect(createBashToolMock).toHaveBeenCalledWith("/project", undefined);
});
it("refuses to start a coding agent in an unregistered worktree", async () => { it("refuses to start a coding agent in an unregistered worktree", async () => {
existsSyncMock.mockImplementation((path) => { existsSyncMock.mockImplementation((path) => {
const value = String(path); const value = String(path);

View File

@@ -737,6 +737,32 @@ describe("StepSessionExecutor", () => {
}); });
describe("sequential execution", () => { describe("sequential execution", () => {
it("forwards taskEnv into step session creation", async () => {
const prompt = makeStepPrompt("FN-001", 1);
const task = makeTaskDetail({ prompt, steps: [{ name: "Step 0", status: "pending" }] });
const settings = makeSettings({ maxParallelSteps: 1 });
const session = makeMockSession();
mockedCreateFnAgent.mockResolvedValue({ session } as any);
const executor = new StepSessionExecutor({
taskDetail: task,
worktreePath: "/project/.worktrees/main",
rootDir: "/project",
settings,
pluginRunner: undefined,
taskEnv: { PATH: "/task/bin", TASK_ONLY: "1" },
} as any);
const result = await executor.executeAll();
expect(result).toHaveLength(1);
expect(result[0]?.success).toBe(true);
expect(mockedCreateFnAgent).toHaveBeenCalledWith(
expect.objectContaining({
taskEnv: { PATH: "/task/bin", TASK_ONLY: "1" },
}),
);
});
it("happy path: 3-step task, all steps succeed", async () => { it("happy path: 3-step task, all steps succeed", async () => {
const prompt = makeStepPrompt("FN-001", 3); const prompt = makeStepPrompt("FN-001", 3);
const task = makeTaskDetail({ prompt, steps: [ const task = makeTaskDetail({ prompt, steps: [

View File

@@ -78,6 +78,8 @@ export interface AgentRuntimeOptions {
skills?: string[]; skills?: string[];
/** Runtime-facing context for non-pi runtimes that cannot consume JS ToolDefinition objects directly. */ /** Runtime-facing context for non-pi runtimes that cannot consume JS ToolDefinition objects directly. */
runtimeContext?: AgentRuntimeContext; runtimeContext?: AgentRuntimeContext;
/** Optional task-scoped environment variables for session-local subprocesses. */
taskEnv?: NodeJS.ProcessEnv;
/** /**
* Last-chance abort hook fired by the runtime *immediately before* the * Last-chance abort hook fired by the runtime *immediately before* the
* underlying LLM session is instantiated — i.e., after all of the runtime's * underlying LLM session is instantiated — i.e., after all of the runtime's

View File

@@ -45,13 +45,9 @@ export interface ResolvedSessionOptions extends AgentRuntimeOptions {
/** Optional runtime hint from task/agent configuration */ /** Optional runtime hint from task/agent configuration */
runtimeHint?: string; runtimeHint?: string;
/** /**
* `beforeSpawnSession` is inherited from {@link AgentRuntimeOptions} — see * `beforeSpawnSession` and `taskEnv` are inherited from
* its definition there for the contract. Callers (e.g. the reviewer's * {@link AgentRuntimeOptions}. Both are forwarded verbatim to
* pause gate) throw from this callback to cancel session creation when * `runtime.createSession()`.
* external state changed during the async setup window. Forwarded
* verbatim to `runtime.createSession()`; the runtime is responsible for
* invoking it at its latest synchronous point before the underlying LLM
* session is instantiated.
*/ */
} }

View File

@@ -2507,9 +2507,9 @@ export class TaskExecutor {
}); });
const pathPrepend = runtimeEnvContribution?.pathPrepend ?? []; const pathPrepend = runtimeEnvContribution?.pathPrepend ?? [];
const injectedEnv = runtimeEnvContribution?.env ?? {}; const injectedEnv = runtimeEnvContribution?.env ?? {};
// We intentionally do NOT mutate process.env globally. Agent session subprocesses // We intentionally do NOT mutate process.env globally. This task-scoped env is
// currently inherit the engine process env only; piping taskEnv into AgentRuntimeOptions // passed through AgentRuntimeOptions so executor session subprocesses inherit it
// is tracked as follow-up work. // without leaking across concurrent tasks.
taskEnv = { taskEnv = {
...process.env, ...process.env,
...injectedEnv, ...injectedEnv,
@@ -3013,7 +3013,7 @@ export class TaskExecutor {
...(executionMode !== "fast" ? [ ...(executionMode !== "fast" ? [
this.createReviewStepTool(task.id, worktreePath, detail.prompt, codeReviewVerdicts, sessionRef, stepCheckpoints, detail, stuckDetector), this.createReviewStepTool(task.id, worktreePath, detail.prompt, codeReviewVerdicts, sessionRef, stepCheckpoints, detail, stuckDetector),
] : []), ] : []),
this.createSpawnAgentTool(task.id, worktreePath, settings), this.createSpawnAgentTool(task.id, worktreePath, settings, taskEnv),
this.createTaskDocumentWriteTool(task.id), this.createTaskDocumentWriteTool(task.id),
this.createTaskDocumentReadTool(task.id), this.createTaskDocumentReadTool(task.id),
...(isResearchToolSurfaceEnabled(settings) ...(isResearchToolSurfaceEnabled(settings)
@@ -3142,6 +3142,7 @@ export class TaskExecutor {
fallbackModelId: executorFallbackModelId, fallbackModelId: executorFallbackModelId,
defaultThinkingLevel: executorThinkingLevel, defaultThinkingLevel: executorThinkingLevel,
sessionManager, sessionManager,
taskEnv,
// Skill selection: use assigned agent skills if available, otherwise role fallback // Skill selection: use assigned agent skills if available, otherwise role fallback
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
actionGateContext: this.buildActionGateContext(task.id, assignedAgent), actionGateContext: this.buildActionGateContext(task.id, assignedAgent),
@@ -3472,6 +3473,7 @@ export class TaskExecutor {
fallbackModelId: executorFallbackModelId, fallbackModelId: executorFallbackModelId,
defaultThinkingLevel: executorThinkingLevel, defaultThinkingLevel: executorThinkingLevel,
sessionManager: SessionManager.create(worktreePath), sessionManager: SessionManager.create(worktreePath),
taskEnv,
// Skill selection: use assigned agent skills if available, otherwise role fallback // Skill selection: use assigned agent skills if available, otherwise role fallback
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
actionGateContext: this.buildActionGateContext(task.id, assignedAgent), actionGateContext: this.buildActionGateContext(task.id, assignedAgent),
@@ -4927,6 +4929,7 @@ Do not refactor, rename broadly, or make opportunistic improvements.
defaultProvider: executorProvider, defaultProvider: executorProvider,
defaultModelId: executorModelId, defaultModelId: executorModelId,
defaultThinkingLevel: settings.defaultThinkingLevel, defaultThinkingLevel: settings.defaultThinkingLevel,
taskEnv: extraEnv,
...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), ...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
}); });
@@ -5369,7 +5372,7 @@ ${failureFeedback}
try { try {
const result: WorkflowStepOutcome = stepMode === "script" const result: WorkflowStepOutcome = stepMode === "script"
? await this.executeScriptWorkflowStep(task, ws, worktreePath, settings, taskEnv) ? await this.executeScriptWorkflowStep(task, ws, worktreePath, settings, taskEnv)
: await this.executeWorkflowStep(task, ws, worktreePath, settings); : await this.executeWorkflowStep(task, ws, worktreePath, settings, taskEnv);
if (await this.shouldDeferWorkflowStepCompletion(task.id, `workflow step '${ws.name}'`)) { if (await this.shouldDeferWorkflowStepCompletion(task.id, `workflow step '${ws.name}'`)) {
return "deferred-paused"; return "deferred-paused";
} }
@@ -5532,6 +5535,7 @@ ${failureFeedback}
workflowStep: WorkflowStep, workflowStep: WorkflowStep,
worktreePath: string, worktreePath: string,
settings: Settings, settings: Settings,
taskEnv?: NodeJS.ProcessEnv,
): Promise<WorkflowStepOutcome> { ): Promise<WorkflowStepOutcome> {
const toolMode: "coding" | "readonly" = workflowStep.toolMode || "readonly"; const toolMode: "coding" | "readonly" = workflowStep.toolMode || "readonly";
@@ -5684,6 +5688,7 @@ and show an appropriate message to the user.\`
fallbackProvider: settings.fallbackProvider, fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId, fallbackModelId: settings.fallbackModelId,
defaultThinkingLevel: settings.defaultThinkingLevel, defaultThinkingLevel: settings.defaultThinkingLevel,
taskEnv,
// Skill selection: use assigned agent skills if available, otherwise role fallback // Skill selection: use assigned agent skills if available, otherwise role fallback
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
}); });
@@ -7152,7 +7157,12 @@ and show an appropriate message to the user.\`
* Create the fn_spawn_agent tool definition. * Create the fn_spawn_agent tool definition.
* Allows the parent agent to spawn child agents with delegated tasks. * Allows the parent agent to spawn child agents with delegated tasks.
*/ */
private createSpawnAgentTool(taskId: string, worktreePath: string, settings: Settings): ToolDefinition { private createSpawnAgentTool(
taskId: string,
worktreePath: string,
settings: Settings,
taskEnv?: NodeJS.ProcessEnv,
): ToolDefinition {
return { return {
name: "fn_spawn_agent", name: "fn_spawn_agent",
label: "Spawn Agent", label: "Spawn Agent",
@@ -7264,6 +7274,7 @@ Child agent: ${agent.id} (${name})`;
defaultModelId: childExecutorModelId, defaultModelId: childExecutorModelId,
fallbackProvider: settings.fallbackProvider, fallbackProvider: settings.fallbackProvider,
fallbackModelId: settings.fallbackModelId, fallbackModelId: settings.fallbackModelId,
taskEnv,
// Skill selection: use assigned agent skills if available, otherwise role fallback // Skill selection: use assigned agent skills if available, otherwise role fallback
...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
}); });

View File

@@ -749,6 +749,8 @@ export interface AgentOptions {
* (and `skillSelection` is not), auto-constructs a SkillSelectionContext * (and `skillSelection` is not), auto-constructs a SkillSelectionContext
* from the cwd and these names. Ignored when `skillSelection` is set. */ * from the cwd and these names. Ignored when `skillSelection` is set. */
skills?: string[]; skills?: string[];
/** Optional task-scoped env injected into this session's subprocess tools only. */
taskEnv?: NodeJS.ProcessEnv;
/** Last-chance abort hook fired immediately before `createAgentSession`. /** Last-chance abort hook fired immediately before `createAgentSession`.
* See `AgentRuntimeOptions.beforeSpawnSession`. */ * See `AgentRuntimeOptions.beforeSpawnSession`. */
beforeSpawnSession?: () => Promise<void> | void; beforeSpawnSession?: () => Promise<void> | void;
@@ -1598,6 +1600,19 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
// Grep→grep). When a coding session ran via Claude CLI tried `Glob`, pi // Grep→grep). When a coding session ran via Claude CLI tried `Glob`, pi
// returned "Tool find not found" and the agent looped. Compose explicitly // returned "Tool find not found" and the agent looped. Compose explicitly
// so every tool referenced by tool-mapping.ts is registered. // so every tool referenced by tool-mapping.ts is registered.
const bashToolOptions = options.taskEnv
? {
spawnHook: ({ command, cwd, env }: { command: string; cwd: string; env: NodeJS.ProcessEnv }) => ({
command,
cwd,
env: {
...env,
...options.taskEnv,
},
}),
}
: undefined;
const tools = const tools =
options.tools === "readonly" options.tools === "readonly"
? [ ? [
@@ -1608,7 +1623,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
] ]
: [ : [
createReadTool(options.cwd), createReadTool(options.cwd),
createBashTool(options.cwd), createBashTool(options.cwd, bashToolOptions),
createEditTool(options.cwd), createEditTool(options.cwd),
createWriteTool(options.cwd), createWriteTool(options.cwd),
createGrepTool(options.cwd), createGrepTool(options.cwd),

View File

@@ -1004,6 +1004,7 @@ Follow instructions precisely and avoid unrelated changes.`,
taskId: taskDetail.id, taskId: taskDetail.id,
taskTitle: taskDetail.title, taskTitle: taskDetail.title,
}), }),
taskEnv: this.options.taskEnv,
}); });
session = createResult.session; session = createResult.session;

View File

@@ -60,7 +60,7 @@ Generated artifacts are expected under:
When the plugin contributes `executorRuntimeEnv`, executor-spawned task commands receive extra runtime wiring: When the plugin contributes `executorRuntimeEnv`, executor-spawned task commands receive extra runtime wiring:
- Generated CLI artifact directories for each service's latest `generated` spec are prepended to task `PATH` (deduped, absolute paths only). - Generated CLI artifact directories for each service's latest `generated` spec are prepended to task `PATH` (deduped, absolute paths only).
- Credentials with `kind: "env_var"` are decoded and injected as environment variables for task subprocesses. - Credentials with `kind: "env_var"` are decoded and injected as environment variables for task subprocesses, including executor agent-session subprocesses (for example `bash` tool commands run inside `createFnAgent(...)`).
- Non-env credential kinds (`header`, `query_param`, `basic_auth`, `bearer_token`, `api_key`) are intentionally excluded from env injection and remain request-time concerns. - Non-env credential kinds (`header`, `query_param`, `basic_auth`, `bearer_token`, `api_key`) are intentionally excluded from env injection and remain request-time concerns.
Security model: Security model: