feat(engine,core,api): CLI nodes run arbitrary commands with trust-on-first-use approval
CLI prompt nodes now accept a raw cliCommand (any command + args), not just named scripts. A raw command must be explicitly approved by the user before it runs: an unapproved command pauses the task (status awaiting-cli-approval) with the command shown; the user approves via POST /tasks/:id/workflow/approve-cli, which records the exact command string in settings.approvedWorkflowCliCommands and resumes. Named scripts (settings.scripts) still never require approval. Adds POST /tasks/:id/workflow/input to answer await-input nodes (records a steering comment + resumes).
This commit is contained in:
@@ -186,6 +186,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
globalPause: false,
|
||||
globalPauseReason: undefined,
|
||||
defaultWorkflowId: undefined,
|
||||
approvedWorkflowCliCommands: undefined,
|
||||
enginePaused: false,
|
||||
maxConcurrent: 2,
|
||||
maxTriageConcurrent: 2,
|
||||
|
||||
@@ -11163,6 +11163,28 @@ ${stepsSection}`;
|
||||
await this.updateSettings({ defaultWorkflowId: workflowId } as unknown as Partial<Settings>);
|
||||
}
|
||||
|
||||
/** Whether a raw workflow CLI command has been approved (trust-on-first-use).
|
||||
* Comparison is on the exact trimmed command string. */
|
||||
async isWorkflowCliCommandApproved(command: string): Promise<boolean> {
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed) return false;
|
||||
const settings = await this.getSettings();
|
||||
const approved = (settings as { approvedWorkflowCliCommands?: string[] }).approvedWorkflowCliCommands;
|
||||
return Array.isArray(approved) && approved.includes(trimmed);
|
||||
}
|
||||
|
||||
/** Record approval for a raw workflow CLI command. Idempotent. */
|
||||
async approveWorkflowCliCommand(command: string): Promise<void> {
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed) throw new Error("CLI command is required");
|
||||
const settings = await this.getSettings();
|
||||
const approved = (settings as { approvedWorkflowCliCommands?: string[] }).approvedWorkflowCliCommands ?? [];
|
||||
if (approved.includes(trimmed)) return;
|
||||
await this.updateSettings({
|
||||
approvedWorkflowCliCommands: [...approved, trimmed],
|
||||
} as unknown as Partial<Settings>);
|
||||
}
|
||||
|
||||
/** Read the workflow currently selected for a task, if any. */
|
||||
getTaskWorkflowSelection(taskId: string): { workflowId: string; stepIds: string[] } | undefined {
|
||||
const row = this.db
|
||||
|
||||
@@ -2892,6 +2892,10 @@ export interface ProjectSettings {
|
||||
/** Default custom workflow (WF-…) applied to newly created tasks when the
|
||||
* caller does not specify enabledWorkflowSteps. Overridable per task. */
|
||||
defaultWorkflowId?: string;
|
||||
/** Raw CLI commands a user has explicitly approved for workflow CLI nodes
|
||||
* (trust-on-first-use). A node's command must appear here before it runs;
|
||||
* named scripts (settings.scripts) never require approval. */
|
||||
approvedWorkflowCliCommands?: string[];
|
||||
/** Engine pause (soft pause): when true, the scheduler and triage
|
||||
* processor stop dispatching **new** work (scheduling, triage
|
||||
* specification, and auto-merge), but currently running agent sessions
|
||||
|
||||
@@ -4975,6 +4975,21 @@ export function selectTaskWorkflow(
|
||||
);
|
||||
}
|
||||
|
||||
/** Approve the raw CLI command a task is paused on, and resume it. */
|
||||
export function approveTaskWorkflowCli(taskId: string, projectId?: string): Promise<{ approved: string }> {
|
||||
return api<{ approved: string }>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/workflow/approve-cli`, projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/** Submit the user's answer to an await-input node and resume the task. */
|
||||
export function submitTaskWorkflowInput(taskId: string, text: string, projectId?: string): Promise<{ ok: true }> {
|
||||
return api<{ ok: true }>(withProjectId(`/tasks/${encodeURIComponent(taskId)}/workflow/input`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Read the project default workflow. */
|
||||
export function fetchProjectDefaultWorkflow(projectId?: string): Promise<{ workflowId: string | null }> {
|
||||
return api<{ workflowId: string | null }>(withProjectId("/project/default-workflow", projectId));
|
||||
|
||||
@@ -161,6 +161,41 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/tasks/:taskId/workflow/approve-cli — approve the raw CLI command
|
||||
// the task is currently paused on (trust-on-first-use) and resume the run.
|
||||
router.post("/tasks/:taskId/workflow/approve-cli", async (req, res) => {
|
||||
try {
|
||||
const { store } = await getProjectContext(req);
|
||||
const task = await store.getTask(req.params.taskId);
|
||||
const reason = task.pausedReason ?? "";
|
||||
const match = /^workflow-cli-approval:[^:]+:\s*(.*)$/s.exec(reason);
|
||||
const command = (req.body?.command as string | undefined) ?? (match ? match[1].trim() : "");
|
||||
if (!command) throw badRequest("No pending CLI command to approve for this task");
|
||||
await store.approveWorkflowCliCommand(command);
|
||||
await store.updateTask(req.params.taskId, { status: null, paused: false, pausedReason: null });
|
||||
res.json({ approved: command });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/tasks/:taskId/workflow/input — submit the user's answer to an
|
||||
// await-input node (records a steering comment and resumes the task).
|
||||
router.post("/tasks/:taskId/workflow/input", async (req, res) => {
|
||||
try {
|
||||
const { store } = await getProjectContext(req);
|
||||
const text = (req.body?.text as string | undefined)?.trim();
|
||||
if (!text) throw badRequest("Input text is required");
|
||||
await store.addSteeringComment(req.params.taskId, text);
|
||||
await store.updateTask(req.params.taskId, { status: null, paused: false, pausedReason: null });
|
||||
res.json({ ok: true });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/project/default-workflow
|
||||
router.get("/project/default-workflow", async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -3378,6 +3378,59 @@ export class TaskExecutor {
|
||||
return { outcome: "failure", value: "awaiting-user-input" };
|
||||
}
|
||||
|
||||
/** Pause the task for explicit user approval of a raw CLI command. The user
|
||||
* approves via the dashboard, which records the command and unpauses; on the
|
||||
* next run isWorkflowCliCommandApproved returns true and the node executes. */
|
||||
private async pauseForCliApproval(node: WorkflowIrNode, live: TaskDetail, command: string): Promise<WorkflowNodeResult> {
|
||||
const marker = `workflow-cli-approval:${node.id}`;
|
||||
await this.store.logEntry(live.id, `Workflow paused for CLI command approval: ${command}`, undefined, this.getRunContextFor(live.id));
|
||||
await this.store.updateTask(
|
||||
live.id,
|
||||
{ status: "awaiting-cli-approval", paused: true, pausedReason: `${marker}: ${command}` },
|
||||
this.getRunContextFor(live.id),
|
||||
);
|
||||
return { outcome: "failure", value: "awaiting-cli-approval" };
|
||||
}
|
||||
|
||||
/** Run an arbitrary (approved) CLI command in the task worktree, supervised. */
|
||||
private async runRawCliCommand(
|
||||
task: TaskDetail,
|
||||
label: string,
|
||||
command: string,
|
||||
worktreePath: string,
|
||||
extraEnv?: NodeJS.ProcessEnv,
|
||||
): Promise<{ success: boolean; output?: string; error?: string }> {
|
||||
executorLog.log(`${task.id}: workflow node '${label}' executing approved CLI command: ${command}`);
|
||||
await this.store.logEntry(task.id, `Workflow node '${label}' executing CLI command: ${command}`, undefined, this.getRunContextFor(task.id));
|
||||
const abort = new AbortController();
|
||||
this.registerConfiguredCommandController(task.id, abort);
|
||||
try {
|
||||
const result = await runConfiguredCommand(
|
||||
command,
|
||||
worktreePath,
|
||||
120_000,
|
||||
extraEnv,
|
||||
createRunAuditor(this.store, {
|
||||
runId: this.getRunContextFor(task.id)?.runId ?? generateSyntheticRunId("exec-cli", task.id),
|
||||
agentId: this.getRunContextFor(task.id)?.agentId ?? (task.assignedAgentId ?? "executor"),
|
||||
taskId: task.id,
|
||||
phase: "execute",
|
||||
}),
|
||||
abort.signal,
|
||||
);
|
||||
if (abort.signal.aborted) throw this.createConfiguredCommandAbortError(task.id, command);
|
||||
if (result.spawnError || result.timedOut || result.exitCode !== 0) {
|
||||
return { success: false, error: configuredCommandErrorMessage(result) };
|
||||
}
|
||||
return { success: true, output: `CLI command completed successfully` };
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === "AbortError") throw err;
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
} finally {
|
||||
this.unregisterConfiguredCommandController(task.id, abort);
|
||||
}
|
||||
}
|
||||
|
||||
/** Run a custom (non-seam) graph node on the proven WorkflowStep machinery. */
|
||||
private async runGraphCustomNode(
|
||||
node: WorkflowIrNode,
|
||||
@@ -3424,9 +3477,27 @@ export class TaskExecutor {
|
||||
} else if (executorKind === "skill" && typeof cfg.skillName === "string" && cfg.skillName.trim()) {
|
||||
prompt = `Invoke the "${cfg.skillName}" skill with the following input, following the skill's instructions exactly:\n\n${prompt}`;
|
||||
} else if (executorKind === "cli") {
|
||||
// CLI execution routes through script mode with the prompt in the env.
|
||||
const rawCommand = typeof cfg.cliCommand === "string" && cfg.cliCommand.trim() ? cfg.cliCommand.trim() : undefined;
|
||||
if (rawCommand) {
|
||||
// Arbitrary command: gated by trust-on-first-use approval. The exact
|
||||
// command string must have been explicitly approved by the user.
|
||||
if (!(await this.store.isWorkflowCliCommandApproved(rawCommand))) {
|
||||
return this.pauseForCliApproval(node, live, rawCommand);
|
||||
}
|
||||
const env = prompt ? { ...process.env, FUSION_NODE_PROMPT: prompt } : undefined;
|
||||
const out = await this.runRawCliCommand(
|
||||
live,
|
||||
typeof cfg.name === "string" && cfg.name.trim() ? cfg.name : node.id,
|
||||
rawCommand,
|
||||
worktreePath,
|
||||
env,
|
||||
);
|
||||
const blocking = node.kind === "gate" || cfg.gateMode === "gate";
|
||||
return { outcome: out.success || !blocking ? "success" : "failure", value: out.success ? "passed" : "failed" };
|
||||
}
|
||||
// No raw command: fall back to a named script (still required).
|
||||
if (!scriptName) {
|
||||
return { outcome: "failure", value: "cli-script-missing" };
|
||||
return { outcome: "failure", value: "cli-command-missing" };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user