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 {
extractRuntimeHint,
resolveHeartbeatSessionModels,
resolveMergerSessionModel,
} 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", () => {
it("returns undefined for undefined config", () => {
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", () => {
it("uses assigned agent runtime model when both provider and modelId are present", () => {
expect(

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PathLike } from "node:fs";
const createAgentSessionMock = vi.fn();
const createBashToolMock = vi.fn((cwd: string, options?: any) => ({ name: "bash", cwd, options }));
const createCodingToolsMock = vi.fn(() => []);
const createReadOnlyToolsMock = vi.fn(() => []);
const createExtensionRuntimeMock = vi.fn();
@@ -81,7 +82,7 @@ vi.mock("@mariozechner/pi-coding-agent", () => ({
}),
},
createAgentSession: createAgentSessionMock,
createBashTool: () => ({ name: "bash" }),
createBashTool: createBashToolMock,
createCodingTools: createCodingToolsMock,
createEditTool: () => ({ name: "edit" }),
createExtensionRuntime: createExtensionRuntimeMock,
@@ -810,6 +811,7 @@ describe("createFnAgent", () => {
readFileSyncMock.mockReturnValue("{}");
readCustomProvidersMock.mockReturnValue([]);
findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId }));
createBashToolMock.mockClear();
createAgentSessionMock.mockResolvedValue({
session: {
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 () => {
existsSyncMock.mockImplementation((path) => {
const value = String(path);

View File

@@ -737,6 +737,32 @@ describe("StepSessionExecutor", () => {
});
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 () => {
const prompt = makeStepPrompt("FN-001", 3);
const task = makeTaskDetail({ prompt, steps: [

View File

@@ -78,6 +78,8 @@ export interface AgentRuntimeOptions {
skills?: string[];
/** Runtime-facing context for non-pi runtimes that cannot consume JS ToolDefinition objects directly. */
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
* 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 */
runtimeHint?: string;
/**
* `beforeSpawnSession` is inherited from {@link AgentRuntimeOptions} — see
* its definition there for the contract. Callers (e.g. the reviewer's
* pause gate) throw from this callback to cancel session creation when
* 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.
* `beforeSpawnSession` and `taskEnv` are inherited from
* {@link AgentRuntimeOptions}. Both are forwarded verbatim to
* `runtime.createSession()`.
*/
}

View File

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

View File

@@ -749,6 +749,8 @@ export interface AgentOptions {
* (and `skillSelection` is not), auto-constructs a SkillSelectionContext
* from the cwd and these names. Ignored when `skillSelection` is set. */
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`.
* See `AgentRuntimeOptions.beforeSpawnSession`. */
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
// returned "Tool find not found" and the agent looped. Compose explicitly
// 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 =
options.tools === "readonly"
? [
@@ -1608,7 +1623,7 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
]
: [
createReadTool(options.cwd),
createBashTool(options.cwd),
createBashTool(options.cwd, bashToolOptions),
createEditTool(options.cwd),
createWriteTool(options.cwd),
createGrepTool(options.cwd),

View File

@@ -1004,6 +1004,7 @@ Follow instructions precisely and avoid unrelated changes.`,
taskId: taskDetail.id,
taskTitle: taskDetail.title,
}),
taskEnv: this.options.taskEnv,
});
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:
- 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.
Security model: