From ddde3c5b57a5218e1a4a208b44f1c1699c887826 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 13:44:13 -0700 Subject: [PATCH] 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). --- packages/core/src/settings-schema.ts | 1 + packages/core/src/store.ts | 22 ++++++ packages/core/src/types.ts | 4 + packages/dashboard/app/api/legacy.ts | 15 ++++ .../src/routes/register-workflow-routes.ts | 35 +++++++++ packages/engine/src/executor.ts | 75 ++++++++++++++++++- 6 files changed, 150 insertions(+), 2 deletions(-) diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index eddab6e7f0..cf37226559 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -186,6 +186,7 @@ export const DEFAULT_PROJECT_SETTINGS = { globalPause: false, globalPauseReason: undefined, defaultWorkflowId: undefined, + approvedWorkflowCliCommands: undefined, enginePaused: false, maxConcurrent: 2, maxTriageConcurrent: 2, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 61f7515ef8..afb9918de6 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -11163,6 +11163,28 @@ ${stepsSection}`; await this.updateSettings({ defaultWorkflowId: workflowId } as unknown as Partial); } + /** 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 { + 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 { + 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); + } + /** Read the workflow currently selected for a task, if any. */ getTaskWorkflowSelection(taskId: string): { workflowId: string; stepIds: string[] } | undefined { const row = this.db diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 827eccf04d..d0239a524f 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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 diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index d8e010616c..799ead3483 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -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)); diff --git a/packages/dashboard/src/routes/register-workflow-routes.ts b/packages/dashboard/src/routes/register-workflow-routes.ts index 6cb54a2514..6124590d9f 100644 --- a/packages/dashboard/src/routes/register-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-workflow-routes.ts @@ -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 { diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index a74767eedd..079c4c1d23 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -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 { + 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" }; } }