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:
@@ -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