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:
5
.changeset/FN-3961-task-scoped-agent-env.md
Normal file
5
.changeset/FN-3961-task-scoped-agent-env.md
Normal 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.
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()`.
|
||||
*/
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1004,6 +1004,7 @@ Follow instructions precisely and avoid unrelated changes.`,
|
||||
taskId: taskDetail.id,
|
||||
taskTitle: taskDetail.title,
|
||||
}),
|
||||
taskEnv: this.options.taskEnv,
|
||||
});
|
||||
session = createResult.session;
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user