feat(FN-3767): add task runtime env injection into executor commands via pl
Adds executor runtime environment support to the plugin system, enabling plugins to contribute environment variables that get injected into executor commands. The implementation includes new plugin types (`ExecutorRuntimeEnvPlugin`), aggregation in the plugin runner, thread-through into executor com Fusion-Task-Id: FN-3767
This commit is contained in:
7
.changeset/FN-3767-executor-runtime-env.md
Normal file
7
.changeset/FN-3767-executor-runtime-env.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Add a new `executorRuntimeEnv` plugin contribution surface so plugins can inject task-scoped runtime environment variables and PATH prepends for executor-spawned commands.
|
||||
|
||||
The bundled `fusion-plugin-cli-printing-press` now contributes generated CLI artifact directories to task PATH and exports `env_var` credentials into the task environment for executor command execution.
|
||||
@@ -357,6 +357,7 @@ Hybrid evaluator pipeline (FN-3389/FN-3391):
|
||||
- `TaskStore.getPluginStore()` now propagates the configured `globalSettingsDir`/central directory so all CLI and dashboard install paths resolve the same central DB
|
||||
- `PluginLoader` (`plugin-loader.ts`) loads/unloads plugin modules using the effective per-project plugin state
|
||||
- Plugin contributions now include both embedded `uiSlots` and top-level `dashboardViews`
|
||||
- Executor runtime contributions can be provided via `executorRuntimeEnv(taskCtx, ctx)`; the engine aggregates plugin-provided `pathPrepend` + `env` overlays per task and applies them only to executor-spawned user commands (configured commands, verification commands, step-session subprocesses), never to git plumbing subprocesses.
|
||||
- Discovery endpoints:
|
||||
- `GET /api/plugins/ui-slots`
|
||||
- `GET /api/plugins/dashboard-views`
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, expectTypeOf, it } from "vitest";
|
||||
import type {
|
||||
FusionPlugin,
|
||||
PluginPromptContributions,
|
||||
PluginPromptSurface,
|
||||
PluginSetupCheckResult,
|
||||
ExecutorRuntimeEnvContribution,
|
||||
ExecutorRuntimeTaskContext,
|
||||
PluginSetupHooks,
|
||||
PluginSetupManifest,
|
||||
PluginSkillContribution,
|
||||
@@ -129,6 +131,28 @@ describe("plugin contribution type constraints", () => {
|
||||
expect(byPlugin["fusion-plugin-agent-browser"]?.contributions).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("accepts executor runtime env contribution shape and hook typing", () => {
|
||||
const contribution: ExecutorRuntimeEnvContribution = {
|
||||
pathPrepend: ["/tmp/bin"],
|
||||
env: { SERVICE_TOKEN: "redacted" },
|
||||
description: "runtime contribution",
|
||||
};
|
||||
|
||||
const plugin: FusionPlugin = {
|
||||
manifest: { id: "plugin-runtime-env", name: "Runtime Env", version: "1.0.0" },
|
||||
state: "installed",
|
||||
hooks: {},
|
||||
executorRuntimeEnv: (taskCtx: ExecutorRuntimeTaskContext) => ({
|
||||
pathPrepend: [taskCtx.worktreePath],
|
||||
env: { TASK_ID: taskCtx.taskId },
|
||||
}),
|
||||
};
|
||||
|
||||
expect(contribution.pathPrepend?.[0]).toBe("/tmp/bin");
|
||||
expectTypeOf(contribution.env).toEqualTypeOf<Record<string, string> | undefined>();
|
||||
expectTypeOf(plugin.executorRuntimeEnv).toBeFunction();
|
||||
});
|
||||
|
||||
it("compile-time rejects invalid prompt surfaces", () => {
|
||||
const validSurface: PluginPromptSurface = "triage";
|
||||
expect(validSurface).toBe("triage");
|
||||
|
||||
@@ -229,6 +229,9 @@ export type {
|
||||
PluginPromptSurface,
|
||||
PluginPromptContribution,
|
||||
PluginPromptContributions,
|
||||
ExecutorRuntimeTaskContext,
|
||||
ExecutorRuntimeEnvContribution,
|
||||
PluginExecutorRuntimeEnvHook,
|
||||
PluginSetupStatus,
|
||||
PluginSetupCheckResult,
|
||||
PluginSetupHooks,
|
||||
|
||||
@@ -581,6 +581,24 @@ export interface PluginPromptContributions {
|
||||
enabledByDefault?: boolean;
|
||||
}
|
||||
|
||||
export interface ExecutorRuntimeTaskContext {
|
||||
taskId: string;
|
||||
worktreePath: string;
|
||||
rootDir: string;
|
||||
branch?: string;
|
||||
}
|
||||
|
||||
export interface ExecutorRuntimeEnvContribution {
|
||||
pathPrepend?: string[];
|
||||
env?: Record<string, string>;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type PluginExecutorRuntimeEnvHook = (
|
||||
taskCtx: ExecutorRuntimeTaskContext,
|
||||
ctx: PluginContext,
|
||||
) => Promise<ExecutorRuntimeEnvContribution> | ExecutorRuntimeEnvContribution;
|
||||
|
||||
export type PluginSetupStatus = "not-installed" | "installing" | "installed" | "error";
|
||||
|
||||
export interface PluginSetupCheckResult {
|
||||
@@ -678,6 +696,8 @@ export interface FusionPlugin {
|
||||
manifest: PluginSetupManifest;
|
||||
hooks: PluginSetupHooks;
|
||||
};
|
||||
/** Plugin-contributed executor runtime env for task-scoped subprocesses. */
|
||||
executorRuntimeEnv?: PluginExecutorRuntimeEnvHook;
|
||||
}
|
||||
|
||||
// ── Plugin Installation ───────────────────────────────────────────────
|
||||
|
||||
@@ -1226,6 +1226,7 @@ describe("Executor verification gate (FN-3345)", () => {
|
||||
undefined,
|
||||
expect.anything(),
|
||||
"executor",
|
||||
expect.any(Object),
|
||||
);
|
||||
// Task should move to in-review
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-3345", "in-review");
|
||||
@@ -1402,6 +1403,7 @@ describe("Executor verification gate (FN-3345)", () => {
|
||||
undefined,
|
||||
expect.anything(),
|
||||
"executor",
|
||||
expect.any(Object),
|
||||
);
|
||||
// Third call should be build
|
||||
expect(mockedVerification).toHaveBeenNthCalledWith(
|
||||
@@ -1414,6 +1416,7 @@ describe("Executor verification gate (FN-3345)", () => {
|
||||
undefined,
|
||||
expect.anything(),
|
||||
"executor",
|
||||
expect.any(Object),
|
||||
);
|
||||
// Task should move to in-review
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-3345", "in-review");
|
||||
|
||||
144
packages/engine/src/__tests__/executor-runtime-env.test.ts
Normal file
144
packages/engine/src/__tests__/executor-runtime-env.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { FusionPlugin, PluginLoader, PluginStore, TaskStore } from "@fusion/core";
|
||||
import { PluginRunner } from "../plugin-runner.js";
|
||||
import { createLogger } from "../logger.js";
|
||||
|
||||
vi.mock("../logger.js", () => ({
|
||||
createLogger: vi.fn(() => ({ log: vi.fn(), warn: vi.fn(), error: vi.fn() })),
|
||||
executorLog: { log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
describe("PluginRunner.collectExecutorRuntimeEnv", () => {
|
||||
const createMockPlugin = (id: string, executorRuntimeEnv?: FusionPlugin["executorRuntimeEnv"]): FusionPlugin => ({
|
||||
manifest: { id, name: id, version: "1.0.0" },
|
||||
state: "started",
|
||||
hooks: {},
|
||||
executorRuntimeEnv,
|
||||
});
|
||||
|
||||
const createRunner = (plugins: FusionPlugin[]) => {
|
||||
const pluginLoader = {
|
||||
getLoadedPlugins: vi.fn().mockReturnValue(plugins),
|
||||
getPlugin: vi.fn(),
|
||||
getPluginTools: vi.fn().mockReturnValue([]),
|
||||
getPluginRoutes: vi.fn().mockReturnValue([]),
|
||||
getPluginUiSlots: vi.fn().mockReturnValue([]),
|
||||
getPluginUiContributions: vi.fn().mockReturnValue([]),
|
||||
getPluginRuntimes: vi.fn().mockReturnValue([]),
|
||||
getCliProviderContributions: vi.fn().mockReturnValue([]),
|
||||
getPluginSkills: vi.fn().mockReturnValue([]),
|
||||
getPluginWorkflowSteps: vi.fn().mockReturnValue([]),
|
||||
getPluginWorkflowStepTemplates: vi.fn().mockReturnValue([]),
|
||||
getPluginPromptContributions: vi.fn().mockReturnValue([]),
|
||||
getPluginSetupInfo: vi.fn().mockReturnValue([]),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
invokeHook: vi.fn(),
|
||||
loadAllPlugins: vi.fn(),
|
||||
stopAllPlugins: vi.fn(),
|
||||
getPluginSchemaInitHooks: vi.fn().mockReturnValue([]),
|
||||
checkPluginSetup: vi.fn(),
|
||||
installPluginSetup: vi.fn(),
|
||||
uninstallPluginSetup: vi.fn(),
|
||||
loadPlugin: vi.fn(),
|
||||
stopPlugin: vi.fn(),
|
||||
reloadPlugin: vi.fn(),
|
||||
} as unknown as PluginLoader;
|
||||
|
||||
const pluginStore = {
|
||||
getPlugin: vi.fn(async (pluginId: string) => ({ id: pluginId, settings: {} })),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
} as unknown as PluginStore;
|
||||
|
||||
const taskStore = {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
getDatabase: vi.fn(),
|
||||
} as unknown as TaskStore;
|
||||
|
||||
return new PluginRunner({ pluginLoader, pluginStore, taskStore, rootDir: "/repo" });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns empty env/path when no plugins contribute", async () => {
|
||||
const runner = createRunner([]);
|
||||
const result = await runner.collectExecutorRuntimeEnv({ taskId: "FN-1", worktreePath: "/tmp/wt", rootDir: "/repo" });
|
||||
|
||||
expect(result).toEqual({ env: {}, pathPrepend: [], perPluginErrors: [] });
|
||||
});
|
||||
|
||||
it("collects env/path from one plugin", async () => {
|
||||
const runner = createRunner([
|
||||
createMockPlugin("plugin-a", () => ({ pathPrepend: ["/opt/plugin-a/bin"], env: { A_TOKEN: "a" } })),
|
||||
]);
|
||||
|
||||
const result = await runner.collectExecutorRuntimeEnv({ taskId: "FN-1", worktreePath: "/tmp/wt", rootDir: "/repo" });
|
||||
|
||||
expect(result.env).toEqual({ A_TOKEN: "a" });
|
||||
expect(result.pathPrepend).toEqual(["/opt/plugin-a/bin"]);
|
||||
expect(result.perPluginErrors).toEqual([]);
|
||||
});
|
||||
|
||||
it("merges plugins with later env overriding and later path entries first", async () => {
|
||||
const runner = createRunner([
|
||||
createMockPlugin("plugin-a", () => ({ pathPrepend: ["/opt/a/bin"], env: { SHARED: "a", A_ONLY: "1" } })),
|
||||
createMockPlugin("plugin-b", () => ({ pathPrepend: ["/opt/b/bin"], env: { SHARED: "b", B_ONLY: "1" } })),
|
||||
]);
|
||||
|
||||
const result = await runner.collectExecutorRuntimeEnv({ taskId: "FN-1", worktreePath: "/tmp/wt", rootDir: "/repo" });
|
||||
|
||||
expect(result.env).toEqual({ SHARED: "b", A_ONLY: "1", B_ONLY: "1" });
|
||||
expect(result.pathPrepend).toEqual(["/opt/b/bin", "/opt/a/bin"]);
|
||||
});
|
||||
|
||||
it("records per-plugin errors when plugin throws", async () => {
|
||||
const runner = createRunner([
|
||||
createMockPlugin("plugin-ok", () => ({ env: { OK: "1" } })),
|
||||
createMockPlugin("plugin-bad", () => {
|
||||
throw new Error("boom");
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await runner.collectExecutorRuntimeEnv({ taskId: "FN-1", worktreePath: "/tmp/wt", rootDir: "/repo" });
|
||||
|
||||
expect(result.env).toEqual({ OK: "1" });
|
||||
expect(result.perPluginErrors).toHaveLength(1);
|
||||
expect(result.perPluginErrors[0]?.pluginId).toBe("plugin-bad");
|
||||
expect(result.perPluginErrors[0]?.error.message).toContain("boom");
|
||||
});
|
||||
|
||||
it("records schema errors for invalid contribution shapes", async () => {
|
||||
const runner = createRunner([
|
||||
createMockPlugin("plugin-path", () => ({ pathPrepend: ["relative/bin"] })),
|
||||
createMockPlugin("plugin-path-env", () => ({ env: { PATH: "forbidden" } })),
|
||||
createMockPlugin("plugin-value", () => ({ env: { GOOD: "ok", BAD: 42 as unknown as string } })),
|
||||
]);
|
||||
|
||||
const result = await runner.collectExecutorRuntimeEnv({ taskId: "FN-1", worktreePath: "/tmp/wt", rootDir: "/repo" });
|
||||
|
||||
expect(result.env).toEqual({});
|
||||
expect(result.pathPrepend).toEqual([]);
|
||||
expect(result.perPluginErrors).toHaveLength(3);
|
||||
expect(result.perPluginErrors.map((item) => item.pluginId)).toEqual([
|
||||
"plugin-path",
|
||||
"plugin-path-env",
|
||||
"plugin-value",
|
||||
]);
|
||||
});
|
||||
|
||||
it("logs warnings when env keys are overridden", async () => {
|
||||
const runner = createRunner([
|
||||
createMockPlugin("plugin-a", () => ({ env: { SHARED: "a" } })),
|
||||
createMockPlugin("plugin-b", () => ({ env: { SHARED: "b" } })),
|
||||
]);
|
||||
|
||||
await runner.collectExecutorRuntimeEnv({ taskId: "FN-1", worktreePath: "/tmp/wt", rootDir: "/repo" });
|
||||
|
||||
const logger = vi.mocked(createLogger).mock.results.at(-1)?.value as { warn: ReturnType<typeof vi.fn> };
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("key override: SHARED"));
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
import { isAbsolute, join, relative, resolve as resolvePath } from "node:path";
|
||||
import { delimiter, isAbsolute, join, relative, resolve as resolvePath } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent } from "@fusion/core";
|
||||
@@ -172,6 +172,7 @@ async function runConfiguredCommand(
|
||||
command: string,
|
||||
cwd: string,
|
||||
timeoutMs: number,
|
||||
extraEnv?: NodeJS.ProcessEnv,
|
||||
): Promise<RunCommandResult> {
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
@@ -179,6 +180,7 @@ async function runConfiguredCommand(
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
encoding: "utf-8",
|
||||
...(extraEnv !== undefined && { env: extraEnv }),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -1914,7 +1916,7 @@ export class TaskExecutor {
|
||||
if (await this.shouldDeferCompletionForGlobalPause(task.id, "before workflow steps during completed-task recovery")) {
|
||||
return false;
|
||||
}
|
||||
const workflowResult = await this.runWorkflowSteps(task, task.worktree, settings);
|
||||
const workflowResult = await this.runWorkflowSteps(task, task.worktree, settings, undefined);
|
||||
if (workflowResult === "deferred-paused") {
|
||||
if (this.pausedAborted.has(task.id)) {
|
||||
this.pausedAborted.delete(task.id);
|
||||
@@ -2296,6 +2298,7 @@ export class TaskExecutor {
|
||||
let stuckRequeue: boolean | null = null;
|
||||
let taskDone = false;
|
||||
let reviewAddressingActivated = false;
|
||||
let taskEnv: NodeJS.ProcessEnv | undefined;
|
||||
|
||||
try {
|
||||
await this.transitionReviewAddressing(task.id, ["queued"], "in-progress");
|
||||
@@ -2415,7 +2418,7 @@ export class TaskExecutor {
|
||||
if (settings.worktreeInitCommand) {
|
||||
const initStartedAt = Date.now();
|
||||
try {
|
||||
const initResult = await runConfiguredCommand(settings.worktreeInitCommand, worktreePath, 300_000);
|
||||
const initResult = await runConfiguredCommand(settings.worktreeInitCommand, worktreePath, 300_000, taskEnv);
|
||||
if (initResult.spawnError || initResult.timedOut || initResult.exitCode !== 0) {
|
||||
throw new Error(configuredCommandErrorMessage(initResult));
|
||||
}
|
||||
@@ -2442,7 +2445,7 @@ export class TaskExecutor {
|
||||
if (scriptCommand) {
|
||||
const setupStartedAt = Date.now();
|
||||
try {
|
||||
const setupResult = await runConfiguredCommand(scriptCommand, worktreePath, 120_000);
|
||||
const setupResult = await runConfiguredCommand(scriptCommand, worktreePath, 120_000, taskEnv);
|
||||
if (setupResult.spawnError || setupResult.timedOut || setupResult.exitCode !== 0) {
|
||||
throw new Error(configuredCommandErrorMessage(setupResult));
|
||||
}
|
||||
@@ -2496,6 +2499,26 @@ export class TaskExecutor {
|
||||
this.activeWorktrees.set(task.id, worktreePath);
|
||||
executorLog.log(`${task.id}: worktree ready at ${worktreePath}`);
|
||||
|
||||
const runtimeEnvContribution = await this.options.pluginRunner?.collectExecutorRuntimeEnv({
|
||||
taskId: task.id,
|
||||
worktreePath,
|
||||
rootDir: this.rootDir,
|
||||
branch: task.branch ?? undefined,
|
||||
});
|
||||
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.
|
||||
taskEnv = {
|
||||
...process.env,
|
||||
...injectedEnv,
|
||||
PATH: [...pathPrepend, process.env.PATH ?? ""].filter(Boolean).join(delimiter),
|
||||
};
|
||||
executorLog.log(
|
||||
`${task.id}: executor runtime env injected (${pathPrepend.length} PATH entries, ${Object.keys(injectedEnv).length} env keys)`,
|
||||
);
|
||||
|
||||
this.options.onStart?.(task, worktreePath);
|
||||
|
||||
const detail = await this.store.getTask(task.id);
|
||||
@@ -2559,6 +2582,7 @@ export class TaskExecutor {
|
||||
// Pass agentStore and messageStore for delegation and messaging tools
|
||||
agentStore: this.options.agentStore,
|
||||
messageStore: this.options.messageStore,
|
||||
taskEnv,
|
||||
onStepStart: (stepIndex) => {
|
||||
this.options.stuckTaskDetector?.recordProgress(task.id);
|
||||
try {
|
||||
@@ -2650,7 +2674,7 @@ export class TaskExecutor {
|
||||
// and when no verification commands are configured.
|
||||
if (executionMode !== "fast") {
|
||||
if (settings.testCommand?.trim() || settings.buildCommand?.trim()) {
|
||||
const verificationResult = await this.runExecutorDeterministicVerification(task, worktreePath, settings);
|
||||
const verificationResult = await this.runExecutorDeterministicVerification(task, worktreePath, settings, taskEnv);
|
||||
|
||||
if (!verificationResult.allPassed) {
|
||||
const failedType = verificationResult.failedCommand === "testCommand" ? "test" : "build";
|
||||
@@ -2695,6 +2719,7 @@ export class TaskExecutor {
|
||||
settings,
|
||||
attempt,
|
||||
maxFixRetries,
|
||||
taskEnv,
|
||||
);
|
||||
if (fixed) {
|
||||
fixSucceeded = true;
|
||||
@@ -2734,7 +2759,7 @@ export class TaskExecutor {
|
||||
|
||||
// Run workflow steps before moving to in-review — skip in fast mode
|
||||
if (executionMode !== "fast") {
|
||||
const workflowResult = await this.runWorkflowSteps(task, worktreePath, settings);
|
||||
const workflowResult = await this.runWorkflowSteps(task, worktreePath, settings, taskEnv);
|
||||
if (workflowResult === "deferred-paused") {
|
||||
if (await this.parkTaskAfterWorkflowStepPause(task.id)) {
|
||||
this.pausedAborted.delete(task.id);
|
||||
@@ -3348,7 +3373,7 @@ export class TaskExecutor {
|
||||
|
||||
// Run workflow steps before moving to in-review — skip in fast mode
|
||||
if (executionMode !== "fast") {
|
||||
const workflowResult = await this.runWorkflowSteps(task, worktreePath, settings);
|
||||
const workflowResult = await this.runWorkflowSteps(task, worktreePath, settings, taskEnv);
|
||||
if (workflowResult === "deferred-paused") {
|
||||
if (await this.parkTaskAfterWorkflowStepPause(task.id)) {
|
||||
this.pausedAborted.delete(task.id);
|
||||
@@ -3537,7 +3562,7 @@ export class TaskExecutor {
|
||||
|
||||
// Run workflow steps before moving to in-review — skip in fast mode
|
||||
if (executionMode !== "fast") {
|
||||
const workflowResult = await this.runWorkflowSteps(task, worktreePath, settings);
|
||||
const workflowResult = await this.runWorkflowSteps(task, worktreePath, settings, taskEnv);
|
||||
if (workflowResult === "deferred-paused") {
|
||||
if (await this.parkTaskAfterWorkflowStepPause(task.id)) {
|
||||
this.pausedAborted.delete(task.id);
|
||||
@@ -4754,6 +4779,7 @@ ${feedback}
|
||||
task: Task,
|
||||
worktreePath: string,
|
||||
settings: Settings,
|
||||
extraEnv?: NodeJS.ProcessEnv,
|
||||
): Promise<VerificationResult> {
|
||||
const testCommand = settings.testCommand?.trim();
|
||||
const buildCommand = settings.buildCommand?.trim();
|
||||
@@ -4779,7 +4805,7 @@ ${feedback}
|
||||
// Run test command first if configured
|
||||
if (testCommand) {
|
||||
const testResult = await runVerificationCommand(
|
||||
this.store, worktreePath, task.id, testCommand, "test", undefined, executorLog, "executor",
|
||||
this.store, worktreePath, task.id, testCommand, "test", undefined, executorLog, "executor", extraEnv,
|
||||
);
|
||||
result.testResult = testResult;
|
||||
|
||||
@@ -4794,7 +4820,7 @@ ${feedback}
|
||||
// Run build command second if configured
|
||||
if (buildCommand) {
|
||||
const buildResult = await runVerificationCommand(
|
||||
this.store, worktreePath, task.id, buildCommand, "build", undefined, executorLog, "executor",
|
||||
this.store, worktreePath, task.id, buildCommand, "build", undefined, executorLog, "executor", extraEnv,
|
||||
);
|
||||
result.buildResult = buildResult;
|
||||
|
||||
@@ -4833,6 +4859,7 @@ ${feedback}
|
||||
settings: Settings,
|
||||
retryNumber: number,
|
||||
maxRetries: number,
|
||||
extraEnv?: NodeJS.ProcessEnv,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
executorLog.log(`${task.id}: spawning executor verification fix agent (attempt ${retryNumber}/${maxRetries})`);
|
||||
@@ -4961,7 +4988,7 @@ ${failureContext.output.slice(0, VERIFICATION_LOG_MAX_CHARS)}
|
||||
undefined,
|
||||
"executor",
|
||||
);
|
||||
const reRunResult = await this.runExecutorDeterministicVerification(task, worktreePath, settings);
|
||||
const reRunResult = await this.runExecutorDeterministicVerification(task, worktreePath, settings, extraEnv);
|
||||
|
||||
return reRunResult.allPassed;
|
||||
} finally {
|
||||
@@ -5254,6 +5281,7 @@ ${failureFeedback}
|
||||
task: Task,
|
||||
worktreePath: string,
|
||||
settings: Settings,
|
||||
taskEnv?: NodeJS.ProcessEnv,
|
||||
): Promise<WorkflowStepResult | "deferred-paused"> {
|
||||
// Check if task has enabled workflow steps
|
||||
const currentTask = await this.store.getTask(task.id);
|
||||
@@ -5340,7 +5368,7 @@ ${failureFeedback}
|
||||
|
||||
try {
|
||||
const result: WorkflowStepOutcome = stepMode === "script"
|
||||
? await this.executeScriptWorkflowStep(task, ws, worktreePath, settings)
|
||||
? await this.executeScriptWorkflowStep(task, ws, worktreePath, settings, taskEnv)
|
||||
: await this.executeWorkflowStep(task, ws, worktreePath, settings);
|
||||
if (await this.shouldDeferWorkflowStepCompletion(task.id, `workflow step '${ws.name}'`)) {
|
||||
return "deferred-paused";
|
||||
@@ -5459,6 +5487,7 @@ ${failureFeedback}
|
||||
workflowStep: WorkflowStep,
|
||||
worktreePath: string,
|
||||
settings: Settings,
|
||||
extraEnv?: NodeJS.ProcessEnv,
|
||||
): Promise<{ success: boolean; output?: string; error?: string }> {
|
||||
const scriptName = workflowStep.scriptName!.trim();
|
||||
const scriptCommand = settings.scripts?.[scriptName];
|
||||
@@ -5474,7 +5503,7 @@ ${failureFeedback}
|
||||
await this.store.logEntry(task.id, `Workflow step '${workflowStep.name}' executing script '${scriptName}': ${scriptCommand}`);
|
||||
|
||||
try {
|
||||
const scriptResult = await runConfiguredCommand(scriptCommand, worktreePath, 120_000);
|
||||
const scriptResult = await runConfiguredCommand(scriptCommand, worktreePath, 120_000, extraEnv);
|
||||
if (scriptResult.spawnError || scriptResult.timedOut || scriptResult.exitCode !== 0) {
|
||||
return { success: false, error: configuredCommandErrorMessage(scriptResult) };
|
||||
}
|
||||
|
||||
@@ -27,9 +27,11 @@ import type {
|
||||
PluginSetupManifest,
|
||||
PluginSetupHooks,
|
||||
PluginSetupCheckResult,
|
||||
ExecutorRuntimeTaskContext,
|
||||
} from "@fusion/core";
|
||||
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@mariozechner/pi-ai";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { createLogger, executorLog } from "./logger.js";
|
||||
|
||||
// Type for the task store's event data
|
||||
@@ -440,6 +442,78 @@ export class PluginRunner {
|
||||
);
|
||||
}
|
||||
|
||||
async collectExecutorRuntimeEnv(taskCtx: ExecutorRuntimeTaskContext): Promise<{
|
||||
env: Record<string, string>;
|
||||
pathPrepend: string[];
|
||||
perPluginErrors: Array<{ pluginId: string; error: Error }>;
|
||||
}> {
|
||||
const loadedPlugins = this.options.pluginLoader.getLoadedPlugins();
|
||||
const pluginResults: Array<{ pluginId: string; env: Record<string, string>; pathPrepend: string[] }> = [];
|
||||
const perPluginErrors: Array<{ pluginId: string; error: Error }> = [];
|
||||
|
||||
for (const plugin of loadedPlugins) {
|
||||
if (!plugin.executorRuntimeEnv) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pluginId = plugin.manifest.id;
|
||||
try {
|
||||
const settings = await this.getPluginSettings(pluginId);
|
||||
const context: PluginContext = {
|
||||
pluginId,
|
||||
taskStore: this.options.taskStore,
|
||||
settings,
|
||||
logger: this.createPluginLogger(pluginId),
|
||||
emitEvent: (event: string, data: unknown) => {
|
||||
this.log.log(`[plugin:${pluginId}] Event: ${event}`, data);
|
||||
},
|
||||
};
|
||||
|
||||
const contribution = await plugin.executorRuntimeEnv(taskCtx, context);
|
||||
const env = contribution.env ?? {};
|
||||
const pathPrepend = contribution.pathPrepend ?? [];
|
||||
|
||||
if (!Array.isArray(pathPrepend) || pathPrepend.some((entry) => typeof entry !== "string" || !isAbsolute(entry))) {
|
||||
throw new Error("executorRuntimeEnv.pathPrepend must be an array of absolute path strings");
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (key === "PATH") {
|
||||
throw new Error("executorRuntimeEnv.env must not contain PATH; use pathPrepend instead");
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`executorRuntimeEnv.env.${key} must be a string`);
|
||||
}
|
||||
}
|
||||
|
||||
pluginResults.push({ pluginId, env, pathPrepend });
|
||||
} catch (error) {
|
||||
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
||||
perPluginErrors.push({ pluginId, error: normalizedError });
|
||||
this.log.warn(`executorRuntimeEnv failed for plugin ${pluginId}: ${normalizedError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const mergedEnv: Record<string, string> = {};
|
||||
const mergedPathPrepend: string[] = [];
|
||||
|
||||
for (const result of pluginResults) {
|
||||
for (const [key, value] of Object.entries(result.env)) {
|
||||
if (Object.prototype.hasOwnProperty.call(mergedEnv, key)) {
|
||||
this.log.warn(`executorRuntimeEnv key override: ${key} overwritten by plugin ${result.pluginId}`);
|
||||
}
|
||||
mergedEnv[key] = value;
|
||||
}
|
||||
mergedPathPrepend.unshift(...result.pathPrepend);
|
||||
}
|
||||
|
||||
return {
|
||||
env: mergedEnv,
|
||||
pathPrepend: mergedPathPrepend,
|
||||
perPluginErrors,
|
||||
};
|
||||
}
|
||||
|
||||
getPromptContributionsForSurface(surface: PluginPromptSurface): Array<{
|
||||
pluginId: string;
|
||||
contribution: PluginPromptContribution;
|
||||
|
||||
@@ -115,6 +115,8 @@ export interface StepSessionExecutorOptions {
|
||||
actionGateContext?: AgentActionGateContext;
|
||||
/** Optional permanent-agent action gating context. */
|
||||
permanentAgentGating?: PermanentAgentGatingContext;
|
||||
/** Task-scoped environment injected into non-git subprocesses. */
|
||||
taskEnv?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
// ── File Scope Extraction ─────────────────────────────────────────────
|
||||
@@ -1283,7 +1285,7 @@ Follow instructions precisely and avoid unrelated changes.`,
|
||||
// Remove any partial directory left behind so the invariant holds:
|
||||
// "if .worktrees/<slug> exists on disk, it is a fully registered git worktree."
|
||||
try {
|
||||
await execAsync(`rm -rf "${worktreePath}"`, { cwd: rootDir });
|
||||
await execAsync(`rm -rf "${worktreePath}"`, { cwd: rootDir, env: this.options.taskEnv });
|
||||
} catch {
|
||||
// best-effort cleanup; log but don't mask the original error
|
||||
stepExecLog.log(`Warning: failed to remove partial worktree directory after creation failure: ${worktreePath}`);
|
||||
|
||||
@@ -81,6 +81,9 @@ export type {
|
||||
PluginPromptSurface,
|
||||
PluginPromptContribution,
|
||||
PluginPromptContributions,
|
||||
ExecutorRuntimeTaskContext,
|
||||
ExecutorRuntimeEnvContribution,
|
||||
PluginExecutorRuntimeEnvHook,
|
||||
PluginSetupStatus,
|
||||
PluginSetupCheckResult,
|
||||
PluginSetupHooks,
|
||||
|
||||
@@ -54,3 +54,19 @@ Generated artifacts are expected under:
|
||||
### Deletions and filesystem cleanup
|
||||
|
||||
`deleteService`, `deleteSpec`, and `deleteArtifact` remove DB records. v1 intentionally does **not** remove artifact files from disk; cleanup is deferred to **FN-3767**.
|
||||
|
||||
## Executor Runtime Exposure
|
||||
|
||||
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.
|
||||
- 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:
|
||||
|
||||
- Runtime env is merged per task (`process.env` base, plugin env overlay, PATH prepend), without mutating global engine `process.env`.
|
||||
- Secrets are never logged; executor diagnostics only report counts of injected keys/paths.
|
||||
- OAuth credentials are rejected defensively if encountered.
|
||||
|
||||
To opt out for a service, remove generated artifacts or env-var credentials in the FN-3766-backed service configuration model.
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { definePlugin } from "@fusion/plugin-sdk";
|
||||
import { createCliPrintingPressRoutes } from "./routes/wizard-routes.js";
|
||||
import { ensureCliPressSchema } from "./store/cli-press-store.js";
|
||||
import { buildExecutorRuntimeEnv } from "./runtime/executor-runtime-env.js";
|
||||
import { createCliPressStore, ensureCliPressSchema } from "./store/cli-press-store.js";
|
||||
|
||||
const storeByDb = new WeakMap<object, ReturnType<typeof createCliPressStore>>();
|
||||
|
||||
function getStore(taskStore: { getDatabase: () => object }) {
|
||||
const db = taskStore.getDatabase();
|
||||
const existing = storeByDb.get(db);
|
||||
if (existing) return existing;
|
||||
const next = createCliPressStore(db as never);
|
||||
storeByDb.set(db, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
const plugin = definePlugin({
|
||||
manifest: {
|
||||
@@ -14,6 +26,10 @@ const plugin = definePlugin({
|
||||
onSchemaInit: ensureCliPressSchema,
|
||||
},
|
||||
routes: createCliPrintingPressRoutes(),
|
||||
executorRuntimeEnv: (taskCtx, ctx) => {
|
||||
const store = getStore(ctx.taskStore as { getDatabase: () => object });
|
||||
return buildExecutorRuntimeEnv(store, taskCtx, ctx);
|
||||
},
|
||||
dashboardViews: [
|
||||
{
|
||||
viewId: "wizard",
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Database } from "@fusion/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCliPressStore } from "../../store/cli-press-store.js";
|
||||
import { encodeCredentialValue } from "../../store/credentials.js";
|
||||
import { buildExecutorRuntimeEnv } from "../executor-runtime-env.js";
|
||||
|
||||
function createHarness() {
|
||||
const rootDir = mkdtempSync(join(tmpdir(), "cli-press-runtime-env-"));
|
||||
const db = new Database(join(rootDir, ".fusion"), { inMemory: true });
|
||||
db.init();
|
||||
const store = createCliPressStore(db);
|
||||
const warnings: string[] = [];
|
||||
|
||||
const ctx = {
|
||||
pluginId: "fusion-plugin-cli-printing-press",
|
||||
taskStore: undefined,
|
||||
settings: {},
|
||||
logger: {
|
||||
log: () => {},
|
||||
info: () => {},
|
||||
warn: (msg: string) => warnings.push(msg),
|
||||
error: () => {},
|
||||
debug: () => {},
|
||||
},
|
||||
emitEvent: () => {},
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
db.close();
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
return { rootDir, db, store, warnings, ctx, cleanup };
|
||||
}
|
||||
|
||||
describe("buildExecutorRuntimeEnv", () => {
|
||||
it("returns empty env/path when no generated services are present", () => {
|
||||
const h = createHarness();
|
||||
try {
|
||||
const result = buildExecutorRuntimeEnv(h.store, { taskId: "FN-1", worktreePath: h.rootDir, rootDir: h.rootDir }, h.ctx as never);
|
||||
expect(result.pathPrepend).toEqual([]);
|
||||
expect(result.env).toEqual({});
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("adds executable artifact directory and env_var credential", () => {
|
||||
const h = createHarness();
|
||||
try {
|
||||
const service = h.store.createService({
|
||||
slug: "alpha",
|
||||
displayName: "Alpha",
|
||||
baseUrl: "https://example.com",
|
||||
sourceKind: "manual",
|
||||
});
|
||||
const spec = h.store.createSpec({
|
||||
serviceId: service.id,
|
||||
name: "alpha-cli",
|
||||
version: "0.1.0",
|
||||
generatorVersion: "1.0.0",
|
||||
specJson: "{}",
|
||||
status: "generated",
|
||||
generatedAt: new Date().toISOString(),
|
||||
});
|
||||
const relativePath = `plugins/cli-printing-press/artifacts/${service.id}/${spec.id}/alpha`;
|
||||
const absolutePath = join(h.rootDir, ".fusion", relativePath);
|
||||
mkdirSync(join(absolutePath, ".."), { recursive: true });
|
||||
writeFileSync(absolutePath, "#!/bin/sh\necho alpha\n");
|
||||
|
||||
h.store.createArtifact({ cliSpecId: spec.id, kind: "script", path: relativePath, executable: true });
|
||||
h.store.createCredential({
|
||||
serviceId: service.id,
|
||||
name: "api",
|
||||
kind: "env_var",
|
||||
placement: { kind: "env_var", envVar: "ALPHA_TOKEN" },
|
||||
value: encodeCredentialValue("secret-alpha"),
|
||||
});
|
||||
|
||||
const result = buildExecutorRuntimeEnv(h.store, { taskId: "FN-1", worktreePath: h.rootDir, rootDir: h.rootDir }, h.ctx as never);
|
||||
expect(result.pathPrepend).toEqual([join(h.rootDir, ".fusion", "plugins/cli-printing-press/artifacts", service.id, spec.id)]);
|
||||
expect(result.env).toEqual({ ALPHA_TOKEN: "secret-alpha" });
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("deduplicates path entries across services", () => {
|
||||
const h = createHarness();
|
||||
try {
|
||||
const sharedDir = `plugins/cli-printing-press/artifacts/shared/bin`;
|
||||
const sharedExec1 = `${sharedDir}/one`;
|
||||
const sharedExec2 = `${sharedDir}/two`;
|
||||
mkdirSync(join(h.rootDir, ".fusion", sharedDir), { recursive: true });
|
||||
writeFileSync(join(h.rootDir, ".fusion", sharedExec1), "1");
|
||||
writeFileSync(join(h.rootDir, ".fusion", sharedExec2), "2");
|
||||
|
||||
for (const slug of ["one", "two"]) {
|
||||
const service = h.store.createService({ slug, displayName: slug, baseUrl: "https://example.com", sourceKind: "manual" });
|
||||
const spec = h.store.createSpec({
|
||||
serviceId: service.id,
|
||||
name: `${slug}-cli`,
|
||||
version: "0.1.0",
|
||||
generatorVersion: "1.0.0",
|
||||
specJson: "{}",
|
||||
status: "generated",
|
||||
generatedAt: new Date().toISOString(),
|
||||
});
|
||||
h.store.createArtifact({
|
||||
cliSpecId: spec.id,
|
||||
kind: "script",
|
||||
path: slug === "one" ? sharedExec1 : sharedExec2,
|
||||
executable: true,
|
||||
});
|
||||
}
|
||||
|
||||
const result = buildExecutorRuntimeEnv(h.store, { taskId: "FN-1", worktreePath: h.rootDir, rootDir: h.rootDir }, h.ctx as never);
|
||||
expect(result.pathPrepend).toEqual([join(h.rootDir, ".fusion", sharedDir)]);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("skips missing artifacts and logs warning", () => {
|
||||
const h = createHarness();
|
||||
try {
|
||||
const service = h.store.createService({ slug: "missing", displayName: "Missing", baseUrl: "https://example.com", sourceKind: "manual" });
|
||||
const spec = h.store.createSpec({
|
||||
serviceId: service.id,
|
||||
name: "missing-cli",
|
||||
version: "0.1.0",
|
||||
generatorVersion: "1.0.0",
|
||||
specJson: "{}",
|
||||
status: "generated",
|
||||
generatedAt: new Date().toISOString(),
|
||||
});
|
||||
h.store.createArtifact({
|
||||
cliSpecId: spec.id,
|
||||
kind: "script",
|
||||
path: `plugins/cli-printing-press/artifacts/${service.id}/${spec.id}/missing`,
|
||||
executable: true,
|
||||
});
|
||||
|
||||
const result = buildExecutorRuntimeEnv(h.store, { taskId: "FN-1", worktreePath: h.rootDir, rootDir: h.rootDir }, h.ctx as never);
|
||||
expect(result.pathPrepend).toEqual([]);
|
||||
expect(h.warnings.length).toBe(1);
|
||||
expect(h.warnings[0]).toContain("Skipping missing artifact");
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects oauth credentials defensively", () => {
|
||||
const h = createHarness();
|
||||
try {
|
||||
const service = h.store.createService({ slug: "oauth", displayName: "OAuth", baseUrl: "https://example.com", sourceKind: "manual" });
|
||||
const encoded = JSON.stringify(encodeCredentialValue("token"));
|
||||
const placement = JSON.stringify({ kind: "oauth", provider: "x" });
|
||||
h.db.prepare("INSERT INTO cli_press_credentials (id, serviceId, name, kind, value, placement, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))")
|
||||
.run("cred_oauth", service.id, "oauth", "oauth", encoded, placement);
|
||||
|
||||
expect(() =>
|
||||
buildExecutorRuntimeEnv(h.store, { taskId: "FN-1", worktreePath: h.rootDir, rootDir: h.rootDir }, h.ctx as never),
|
||||
).toThrow(/OAuth credentials are not supported/);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores non env_var credentials", () => {
|
||||
const h = createHarness();
|
||||
try {
|
||||
const service = h.store.createService({ slug: "beta", displayName: "Beta", baseUrl: "https://example.com", sourceKind: "manual" });
|
||||
h.store.createCredential({
|
||||
serviceId: service.id,
|
||||
name: "header",
|
||||
kind: "header",
|
||||
placement: { kind: "header", header: "X-Token" },
|
||||
value: encodeCredentialValue("header-token"),
|
||||
});
|
||||
|
||||
const result = buildExecutorRuntimeEnv(h.store, { taskId: "FN-1", worktreePath: h.rootDir, rootDir: h.rootDir }, h.ctx as never);
|
||||
expect(result.env).toEqual({});
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { dirname, isAbsolute, join } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { ExecutorRuntimeEnvContribution, ExecutorRuntimeTaskContext, PluginContext } from "@fusion/plugin-sdk";
|
||||
import type { createCliPressStore } from "../store/cli-press-store.js";
|
||||
import { decodeCredentialValue } from "../store/credentials.js";
|
||||
|
||||
type CliPressStore = ReturnType<typeof createCliPressStore>;
|
||||
|
||||
function toEpoch(value?: string): number {
|
||||
if (!value) return 0;
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
export function buildExecutorRuntimeEnv(
|
||||
store: CliPressStore,
|
||||
taskCtx: ExecutorRuntimeTaskContext,
|
||||
ctx: PluginContext,
|
||||
): ExecutorRuntimeEnvContribution {
|
||||
const pathDirs: string[] = [];
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
for (const service of store.listServices()) {
|
||||
const specs = store
|
||||
.listSpecs(service.id)
|
||||
.filter((spec) => spec.status === "generated")
|
||||
.sort((a, b) => toEpoch(b.generatedAt ?? b.updatedAt) - toEpoch(a.generatedAt ?? a.updatedAt));
|
||||
|
||||
const selectedSpec = specs.find((spec) => {
|
||||
const artifacts = store.listArtifacts(spec.id);
|
||||
return artifacts.some((artifact) => artifact.executable);
|
||||
});
|
||||
|
||||
if (selectedSpec) {
|
||||
const executableArtifacts = store.listArtifacts(selectedSpec.id).filter((artifact) => artifact.executable);
|
||||
for (const artifact of executableArtifacts) {
|
||||
const absoluteArtifactPath = isAbsolute(artifact.path)
|
||||
? artifact.path
|
||||
: join(taskCtx.rootDir, ".fusion", artifact.path);
|
||||
if (!existsSync(absoluteArtifactPath)) {
|
||||
ctx.logger.warn(
|
||||
`[executorRuntimeEnv] Skipping missing artifact for service ${service.slug}: ${absoluteArtifactPath}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
pathDirs.push(dirname(absoluteArtifactPath));
|
||||
}
|
||||
}
|
||||
|
||||
for (const credential of store.listCredentials(service.id)) {
|
||||
const credentialKind = (credential as { kind: string }).kind;
|
||||
if (credentialKind === "oauth" || credentialKind === "oauth2") {
|
||||
throw new Error(`OAuth credentials are not supported for service ${service.slug}`);
|
||||
}
|
||||
|
||||
if (credential.kind !== "env_var") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (credential.placement.kind !== "env_var") {
|
||||
throw new Error(
|
||||
`Credential placement mismatch for ${credential.name}: expected env_var placement, got ${credential.placement.kind}`,
|
||||
);
|
||||
}
|
||||
|
||||
env[credential.placement.envVar] = decodeCredentialValue(credential.value);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pathPrepend: Array.from(new Set(pathDirs)),
|
||||
env,
|
||||
description: "cli-printing-press generated CLIs",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user