From 243113a3cf3f68126f1fa1037249a862648d2927 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 5 Jun 2026 00:30:27 -0700 Subject: [PATCH] feat: cli-agent adapter settings, autonomy approval gate, and node editor config (U15) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cli-agent-settings-autonomy-gate.md | 32 ++ .../global-settings-cli-agents.test.ts | 134 ++++++++ packages/core/src/global-settings.ts | 6 + packages/core/src/index.ts | 4 +- packages/core/src/settings-schema.ts | 82 ++++- packages/core/src/store.ts | 44 +++ packages/core/src/types.ts | 43 +++ .../app/components/SettingsModal.tsx | 235 +++++++++++++ .../app/components/WorkflowNodeEditor.tsx | 116 ++++++- .../WorkflowNodeEditor.cli-agent.test.tsx | 149 +++++++++ packages/dashboard/src/routes.ts | 5 + .../cli-agent-settings-route.test.ts | 162 +++++++++ .../src/routes/cli-agent-settings.ts | 131 ++++++++ .../src/cli-agent/__tests__/autonomy.test.ts | 182 ++++++++++ packages/engine/src/cli-agent/adapter.ts | 38 +++ .../src/cli-agent/adapters/claude-code.ts | 16 + .../engine/src/cli-agent/adapters/codex.ts | 29 ++ .../engine/src/cli-agent/adapters/droid.ts | 17 + .../engine/src/cli-agent/adapters/generic.ts | 13 + .../engine/src/cli-agent/adapters/index.ts | 58 ++++ packages/engine/src/cli-agent/adapters/pi.ts | 18 + packages/engine/src/cli-agent/autonomy.ts | 316 ++++++++++++++++++ packages/engine/src/cli-agent/task-session.ts | 73 +++- packages/engine/src/index.ts | 28 ++ packages/i18n/locales/en/app.json | 52 ++- 25 files changed, 1977 insertions(+), 6 deletions(-) create mode 100644 .changeset/cli-agent-settings-autonomy-gate.md create mode 100644 packages/core/src/__tests__/global-settings-cli-agents.test.ts create mode 100644 packages/dashboard/app/components/__tests__/WorkflowNodeEditor.cli-agent.test.tsx create mode 100644 packages/dashboard/src/routes/__tests__/cli-agent-settings-route.test.ts create mode 100644 packages/dashboard/src/routes/cli-agent-settings.ts create mode 100644 packages/engine/src/cli-agent/__tests__/autonomy.test.ts create mode 100644 packages/engine/src/cli-agent/adapters/index.ts create mode 100644 packages/engine/src/cli-agent/autonomy.ts diff --git a/.changeset/cli-agent-settings-autonomy-gate.md b/.changeset/cli-agent-settings-autonomy-gate.md new file mode 100644 index 0000000000..c4a9dbb77f --- /dev/null +++ b/.changeset/cli-agent-settings-autonomy-gate.md @@ -0,0 +1,32 @@ +--- +"@runfusion/fusion": minor +--- + +Add CLI-agent adapter launch settings, an autonomy approval gate, and workflow +node-editor configuration for the CLI Agent Executor (U15). + +A new `cliAgents` slice of global settings holds per-adapter operator launch +config — command override, extra args, autonomy mode, and env allowlist +additions — validated and sanitized at the write boundary (unknown adapter ids +and invalid fields are dropped). Shipped defaults are owned by the adapters. + +The autonomy gate closes the "adjacent settings" bypass: elevation requested +through ANY channel (the autonomy field, extra args such as +`--dangerously-skip-permissions`, an autonomy-toggling env var, or a non-default +command override) is detected over the FULLY RESOLVED argv + env via per-adapter +elevation markers plus a shared generic env-pattern set. `resolveEffectivePosture` +derives the posture chip from the resolved invocation — never the autonomy field +alone — and the effective posture is denormalized onto the session record at +spawn. An elevated launch without a stored per-project approval fails with a +typed `CliAutonomyNotApprovedError` instead of stalling. Approvals are per-project ++ per-adapter (mirroring the raw workflow-CLI-command approval precedent) and the +approving principal in v1 is the daemon-token holder. + +The dashboard adds daemon-token-authed routes +(`/api/cli-agents`, `/api/cli-agents/settings`, +`/api/cli-agents/:adapterId/approve-autonomy` + revoke), a Settings section for +per-adapter launch config with an explicit confirmation flow before elevated +autonomy is approved, and a workflow node-editor block that surfaces an adapter +picker (with native/hybrid/generic tier labels), an autonomy toggle, and the +waiting-on-input notification mode (banner / banner+notify) when a node's executor +is `cli-agent`. All new strings are localized in the `app` i18n catalog. diff --git a/packages/core/src/__tests__/global-settings-cli-agents.test.ts b/packages/core/src/__tests__/global-settings-cli-agents.test.ts new file mode 100644 index 0000000000..a33c5a4dc3 --- /dev/null +++ b/packages/core/src/__tests__/global-settings-cli-agents.test.ts @@ -0,0 +1,134 @@ +/** + * cliAgents global-settings slice (U15): round-trip with defaults merge + + * invalid-dropped-at-the-write-boundary behavior. + */ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { GlobalSettingsStore } from "../global-settings.js"; +import { sanitizeCliAgentsSettings, sanitizeCliAgentSettings } from "../settings-schema.js"; + +describe("sanitizeCliAgentSettings (write-boundary validation)", () => { + it("keeps valid fields and trims strings", () => { + expect( + sanitizeCliAgentSettings({ + commandOverride: " /opt/claude ", + extraArgs: [" --foo ", "", "bar"], + envAdditions: ["MY_VAR", " ", "OTHER"], + autonomyMode: "elevated", + }), + ).toEqual({ + commandOverride: "/opt/claude", + extraArgs: ["--foo", "bar"], + envAdditions: ["MY_VAR", "OTHER"], + autonomyMode: "elevated", + }); + }); + + it("drops unknown fields and invalid values", () => { + expect( + sanitizeCliAgentSettings({ + commandOverride: 42, + extraArgs: "not-an-array", + envAdditions: [1, 2, 3], + autonomyMode: "godmode", + bogus: "x", + }), + ).toBeUndefined(); + }); + + it("drops empty-after-trim command override", () => { + expect(sanitizeCliAgentSettings({ commandOverride: " " })).toBeUndefined(); + }); +}); + +describe("sanitizeCliAgentsSettings", () => { + it("drops unknown adapter ids", () => { + const out = sanitizeCliAgentsSettings({ + "claude-code": { autonomyMode: "elevated" }, + "totally-made-up": { autonomyMode: "elevated" }, + }); + expect(Object.keys(out)).toEqual(["claude-code"]); + }); + + it("returns empty object for non-objects", () => { + expect(sanitizeCliAgentsSettings(null)).toEqual({}); + expect(sanitizeCliAgentsSettings([1, 2])).toEqual({}); + expect(sanitizeCliAgentsSettings("x")).toEqual({}); + }); + + it("omits adapter entries that sanitize to nothing", () => { + const out = sanitizeCliAgentsSettings({ + codex: { autonomyMode: "garbage" }, + pi: { extraArgs: ["--ok"] }, + }); + expect(out).toEqual({ pi: { extraArgs: ["--ok"] } }); + }); +}); + +describe("GlobalSettingsStore cliAgents round-trip", () => { + let dir: string; + let store: GlobalSettingsStore; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "fusion-cli-agents-")); + store = new GlobalSettingsStore(dir); + await store.init(); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("defaults cliAgents to an empty object", async () => { + const settings = await store.getSettings(); + expect(settings.cliAgents).toEqual({}); + }); + + it("persists a valid adapter config across a fresh read", async () => { + await store.updateSettings({ + cliAgents: { + "claude-code": { + commandOverride: "/usr/local/bin/claude", + extraArgs: ["--verbose"], + autonomyMode: "elevated", + envAdditions: ["HTTP_PROXY"], + }, + }, + }); + store.invalidateCache(); + const reread = await store.getSettings(); + expect(reread.cliAgents).toEqual({ + "claude-code": { + commandOverride: "/usr/local/bin/claude", + extraArgs: ["--verbose"], + autonomyMode: "elevated", + envAdditions: ["HTTP_PROXY"], + }, + }); + }); + + it("drops invalid adapter ids and fields at the write boundary", async () => { + await store.updateSettings({ + cliAgents: { + // unknown adapter id → dropped + "evil-adapter": { autonomyMode: "elevated" }, + // valid adapter, junk autonomyMode dropped, valid extraArgs kept + codex: { autonomyMode: "yolo", extraArgs: ["--model=gpt"] }, + } as never, + }); + store.invalidateCache(); + const reread = await store.getSettings(); + expect(reread.cliAgents).toEqual({ codex: { extraArgs: ["--model=gpt"] } }); + }); + + it("merges per-adapter without dropping unrelated global keys", async () => { + await store.updateSettings({ themeMode: "light" }); + await store.updateSettings({ cliAgents: { pi: { extraArgs: ["--tools=read"] } } }); + store.invalidateCache(); + const reread = await store.getSettings(); + expect(reread.themeMode).toBe("light"); + expect(reread.cliAgents).toEqual({ pi: { extraArgs: ["--tools=read"] } }); + }); +}); diff --git a/packages/core/src/global-settings.ts b/packages/core/src/global-settings.ts index 304e9badf3..ee8197f578 100644 --- a/packages/core/src/global-settings.ts +++ b/packages/core/src/global-settings.ts @@ -19,6 +19,7 @@ import { mkdir, readFile, writeFile, rename, chmod } from "node:fs/promises"; import { existsSync, mkdirSync, renameSync } from "node:fs"; import type { GlobalSettings } from "./types.js"; import { DEFAULT_GLOBAL_SETTINGS } from "./types.js"; +import { sanitizeCliAgentsSettings } from "./settings-schema.js"; function getHomeDir(): string { return process.env.HOME || process.env.USERPROFILE || homedir(); @@ -193,6 +194,11 @@ export class GlobalSettingsStore { // null → delete this key from the merged object // This effectively makes it fall through to the default delete merged[key]; + } else if (key === "cliAgents") { + // Validation at the write boundary (U15, Global Settings convention): + // unknown adapter ids and invalid fields are dropped before persist so + // a malformed `cliAgents` payload can never reach launch resolution. + merged[key] = sanitizeCliAgentsSettings(value); } else { // normal value → set it merged[key] = value; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d5bf1b5134..a7c0be3fef 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ -export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js"; -export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext } from "./types.js"; +export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES } from "./types.js"; +export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js"; export { resolveEntryPointBranchAssignment, diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index c955e7c5ce..1324f70609 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -1,4 +1,4 @@ -import type { GlobalSettings, ProjectSettings, Settings } from "./types.js"; +import type { CliAgentSettings, GlobalSettings, ProjectSettings, Settings } from "./types.js"; export interface MergeRequestContractShadowSettingsSource { mergeRequestContractShadowEnabled?: boolean; @@ -180,6 +180,7 @@ export const DEFAULT_GLOBAL_SETTINGS = { }, owningNodeHandoffPolicy: "reassign-to-local", experimentalFeatures: {}, + cliAgents: {}, } satisfies CompleteSettings; /** Default values for project-level settings. */ @@ -188,6 +189,7 @@ export const DEFAULT_PROJECT_SETTINGS = { globalPauseReason: undefined, defaultWorkflowId: undefined, approvedWorkflowCliCommands: undefined, + approvedCliAutonomyAdapters: undefined, enginePaused: false, maxConcurrent: 2, maxTriageConcurrent: 2, @@ -521,3 +523,81 @@ export function resolvePersistAgentThinkingLog( if (typeof settings?.persistAgentThinkingLog === "boolean") return settings.persistAgentThinkingLog; return false; } + +// ── CLI-agent settings sanitization (U15) ─────────────────────────────────── + +/** Adapter ids accepted in `cliAgents`. Unknown ids are dropped at the write + * boundary so a settings file cannot carry config for non-existent adapters. */ +export const CLI_AGENT_ADAPTER_IDS = Object.freeze([ + "claude-code", + "codex", + "droid", + "pi", + "generic", +] as const); + +/** Autonomy modes accepted in a `CliAgentSettings` entry. */ +export const CLI_AGENT_AUTONOMY_MODES = Object.freeze(["default", "elevated"] as const); + +function sanitizeStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const cleaned = value + .filter((v): v is string => typeof v === "string") + .map((v) => v.trim()) + .filter((v) => v.length > 0); + return cleaned.length > 0 ? cleaned : undefined; +} + +/** + * Sanitize a single adapter's launch settings (U15). Drops unknown fields and + * invalid values; returns `undefined` when nothing survives (so the caller can + * omit an empty entry). Pure — no I/O. + * + * Validation rules: + * - `commandOverride`: non-empty trimmed string, else dropped. + * - `extraArgs` / `envAdditions`: arrays of non-empty trimmed strings, else dropped. + * - `autonomyMode`: one of CLI_AGENT_AUTONOMY_MODES, else dropped (falls back to + * the adapter baseline at resolution time). + */ +export function sanitizeCliAgentSettings(value: unknown): CliAgentSettings | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const input = value as Record; + const out: CliAgentSettings = {}; + + if (typeof input.commandOverride === "string") { + const trimmed = input.commandOverride.trim(); + if (trimmed.length > 0) out.commandOverride = trimmed; + } + + const extraArgs = sanitizeStringArray(input.extraArgs); + if (extraArgs) out.extraArgs = extraArgs; + + const envAdditions = sanitizeStringArray(input.envAdditions); + if (envAdditions) out.envAdditions = envAdditions; + + if ( + typeof input.autonomyMode === "string" && + (CLI_AGENT_AUTONOMY_MODES as readonly string[]).includes(input.autonomyMode) + ) { + out.autonomyMode = input.autonomyMode as CliAgentSettings["autonomyMode"]; + } + + return Object.keys(out).length > 0 ? out : undefined; +} + +/** + * Sanitize the whole `cliAgents` map at the write boundary (U15). Drops unknown + * adapter ids and any entry that sanitizes to nothing. Returns a fresh object; + * always returns an object (possibly empty) so the field round-trips cleanly. + */ +export function sanitizeCliAgentsSettings(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const input = value as Record; + const out: Record = {}; + for (const adapterId of CLI_AGENT_ADAPTER_IDS) { + if (!(adapterId in input)) continue; + const entry = sanitizeCliAgentSettings(input[adapterId]); + if (entry) out[adapterId] = entry; + } + return out; +} diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 352b281004..c451f6c708 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -12985,6 +12985,50 @@ ${stepsSection}`; } as unknown as Partial); } + /** Whether a CLI-agent adapter has been approved for ELEVATED autonomy in this + * project (CLI Agent Executor, U15). Mirrors the raw-command approval + * precedent; approval is per-project + per-adapter and stored in project + * settings (`approvedCliAutonomyAdapters`). */ + async isCliAutonomyApproved(adapterId: string): Promise { + const trimmed = adapterId.trim(); + if (!trimmed) return false; + const settings = await this.getSettings(); + const approved = (settings as { approvedCliAutonomyAdapters?: string[] }).approvedCliAutonomyAdapters; + return Array.isArray(approved) && approved.includes(trimmed); + } + + /** Record approval for elevated CLI-agent autonomy for an adapter. Idempotent. + * The approving principal in v1 is the daemon-token holder (route-level). */ + async approveCliAutonomy(adapterId: string): Promise { + const trimmed = adapterId.trim(); + if (!trimmed) throw new Error("Adapter id is required"); + const settings = await this.getSettings(); + const approved = (settings as { approvedCliAutonomyAdapters?: string[] }).approvedCliAutonomyAdapters ?? []; + if (approved.includes(trimmed)) return; + await this.updateSettings({ + approvedCliAutonomyAdapters: [...approved, trimmed], + } as unknown as Partial); + } + + /** Revoke a previously-granted elevated-autonomy approval. Idempotent. */ + async revokeCliAutonomy(adapterId: string): Promise { + const trimmed = adapterId.trim(); + if (!trimmed) return; + const settings = await this.getSettings(); + const approved = (settings as { approvedCliAutonomyAdapters?: string[] }).approvedCliAutonomyAdapters ?? []; + if (!approved.includes(trimmed)) return; + await this.updateSettings({ + approvedCliAutonomyAdapters: approved.filter((a) => a !== trimmed), + } as unknown as Partial); + } + + /** List adapters approved for elevated autonomy in this project. */ + async listApprovedCliAutonomyAdapters(): Promise { + const settings = await this.getSettings(); + const approved = (settings as { approvedCliAutonomyAdapters?: string[] }).approvedCliAutonomyAdapters; + return Array.isArray(approved) ? [...approved] : []; + } + /** Read the workflow currently selected for a task, if any. */ /** * Synchronously resolve the parsed WorkflowIr that governs a task's columns diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a856b620fb..d5a31992a7 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2942,6 +2942,39 @@ export interface GlobalSettings { * * Default: {} (empty object — no experimental features enabled). */ experimentalFeatures?: Record; + /** Per-adapter CLI-agent launch configuration (CLI Agent Executor, U15). + * Keyed by adapter id (e.g. `"claude-code"`, `"codex"`, `"generic"`). Each + * entry carries operator overrides layered over the adapter's shipped + * defaults: a command override, extra args, an autonomy mode, and env + * allowlist additions. Validated + sanitized at the write boundary + * (`sanitizeCliAgentsSettings`); invalid entries/fields are dropped. + * + * Note: elevation expressed through ANY of these channels (autonomy mode, + * extra args, env additions, a non-default command override) is gated by a + * stored per-project approval at launch — see `@fusion/engine`'s + * `resolveEffectivePosture`. These settings only describe *intent*; the + * engine resolves and enforces posture. Default: {} (no overrides). */ + cliAgents?: Record; +} + +/** Operator launch config for one CLI-agent adapter (U15). Values are layered + * over the adapter's shipped defaults at launch. All fields optional; an empty + * object means "use shipped defaults". */ +export interface CliAgentSettings { + /** Override for the binary path/name to invoke. A non-default value is treated + * as privileged (routes through the autonomy approval gate). */ + commandOverride?: string; + /** Extra args appended after the adapter's computed base args. Free-form; the + * engine's elevation detector scans these for bypass markers. */ + extraArgs?: string[]; + /** Autonomy mode above the adapter baseline. `"default"` is the baseline (no + * elevation); `"elevated"` requests bypass-permissions-style autonomy and is + * gated. Kept as a string enum so adapters can map it to their own flags. */ + autonomyMode?: "default" | "elevated"; + /** Additional env var KEYS to forward from the parent process to the child. + * Names only (never values); the engine copies these from `process.env`. + * Service credentials (`FUSION_*`) are always excluded regardless. */ + envAdditions?: string[]; } export type RemoteAccessProvider = "tailscale" | "cloudflare"; @@ -3031,6 +3064,12 @@ export interface ProjectSettings { * (trust-on-first-use). A node's command must appear here before it runs; * named scripts (settings.scripts) never require approval. */ approvedWorkflowCliCommands?: string[]; + /** CLI-agent adapter ids the project owner has approved for ELEVATED autonomy + * (CLI Agent Executor, U15). An adapter must appear here before a launch whose + * resolved posture is elevated (bypass-permissions-style) is permitted; an + * unapproved elevation fails the launch with a typed error. Approving + * principal in v1: the daemon-token holder (the single workspace owner). */ + approvedCliAutonomyAdapters?: 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 @@ -3891,6 +3930,10 @@ export { isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, + sanitizeCliAgentSettings, + sanitizeCliAgentsSettings, + CLI_AGENT_ADAPTER_IDS, + CLI_AGENT_AUTONOMY_MODES, } from "./settings-schema.js"; export interface BoardConfig { diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index f268a491eb..c6b91f7ace 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -263,6 +263,7 @@ const SETTINGS_SECTIONS: SettingsSection[] = [ { id: "notifications", label: "Notifications", labelKey: "settings.nav.notifications", scope: "global" }, { id: "node-sync", label: "Node Sync", labelKey: "settings.nav.nodeSync", scope: "global" }, { id: "global-models", label: "Models", labelKey: "settings.nav.globalModels", scope: "global" }, + { id: "cli-agents", label: "CLI Agents", labelKey: "settings.nav.cliAgents", scope: "global" }, { id: "research-global", label: "Research Defaults", labelKey: "settings.nav.researchGlobal", scope: "global" }, { id: "experimental", label: "Experimental Features", labelKey: "settings.nav.experimental", scope: "global" }, { id: "remote", label: "Remote Access", labelKey: "settings.nav.remote", scope: "global" }, @@ -422,6 +423,233 @@ interface SettingsModalProps { onOpenApprovals?: (approvalId?: string) => void; } +/** Adapter descriptor served by GET /api/cli-agents (U15). */ +interface CliAdapterDescriptorView { + id: string; + name: string; + tier: "native" | "hybrid" | "generic"; + defaultCommand: string | null; +} + +interface CliAgentSettingsEntry { + commandOverride?: string; + extraArgs?: string[]; + autonomyMode?: "default" | "elevated"; + envAdditions?: string[]; +} + +/** + * Per-adapter CLI-agent launch settings section (U15). Reads the adapter catalog + * + persisted settings + per-project autonomy approval state, and lets the + * operator edit command override / extra args / env additions / autonomy mode. + * Switching an adapter to elevated autonomy goes through an explicit + * confirmation flow before the per-project approval is granted. + */ +function CliAgentsSettingsSection({ + projectId, + addToast, +}: { + projectId?: string; + addToast: (message: string, type?: ToastType) => void; +}) { + const { t } = useTranslation("app"); + const { confirm } = useConfirm(); + const [adapters, setAdapters] = useState([]); + const [settings, setSettings] = useState>({}); + const [approved, setApproved] = useState>({}); + const [selectedId, setSelectedId] = useState(""); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const [catRes, setRes] = await Promise.all([ + fetch("/api/cli-agents"), + fetch("/api/cli-agents/settings"), + ]); + const cat = catRes.ok ? await catRes.json() : { adapters: [] }; + const set = setRes.ok ? await setRes.json() : { cliAgents: {} }; + if (cancelled) return; + const list = (cat.adapters ?? []) as CliAdapterDescriptorView[]; + setAdapters(list); + setSettings((set.cliAgents ?? {}) as Record); + if (list.length > 0) setSelectedId((prev) => prev || list[0].id); + // Approval state is per-adapter; fetch lazily per selection below. + } catch { + // Non-fatal: render the static fallback list. + } + })(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!selectedId) return; + let cancelled = false; + fetch(`/api/cli-agents/${selectedId}/autonomy`) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (cancelled || !data) return; + setApproved((prev) => ({ ...prev, [selectedId]: Boolean(data.approved) })); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [selectedId]); + + const current = settings[selectedId] ?? {}; + + const persist = useCallback( + async (adapterId: string, config: CliAgentSettingsEntry) => { + try { + const res = await fetch("/api/cli-agents/settings", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ adapterId, config }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + setSettings((data.cliAgents ?? {}) as Record); + } catch (err) { + addToast(getErrorMessage(err) || t("settings.cliAgents.saveFailed"), "error"); + } + }, + [addToast, t], + ); + + const updateCurrent = useCallback( + (patch: Partial) => { + if (!selectedId) return; + const next = { ...current, ...patch }; + setSettings((prev) => ({ ...prev, [selectedId]: next })); + void persist(selectedId, next); + }, + [selectedId, current, persist], + ); + + const onAutonomyChange = useCallback( + async (mode: "default" | "elevated") => { + if (!selectedId) return; + if (mode === "elevated") { + const ok = await confirm({ + title: t("settings.cliAgents.elevatedConfirmTitle"), + message: t("settings.cliAgents.elevatedConfirmBody"), + confirmLabel: t("settings.cliAgents.elevatedConfirmAction"), + danger: true, + }); + if (!ok) return; + // Record the per-project approval first, then persist the mode. + try { + const res = await fetch(`/api/cli-agents/${selectedId}/approve-autonomy`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: true }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + setApproved((prev) => ({ ...prev, [selectedId]: true })); + } catch (err) { + addToast(getErrorMessage(err) || t("settings.cliAgents.approveFailed"), "error"); + return; + } + } + updateCurrent({ autonomyMode: mode }); + }, + [selectedId, confirm, t, addToast, updateCurrent], + ); + + return ( +
+

{t("settings.cliAgents.heading")}

+

{t("settings.cliAgents.description")}

+ +
+ + +
+ + {selectedId && ( + <> +
+ + a.id === selectedId)?.defaultCommand ?? "" + } + value={current.commandOverride ?? ""} + onChange={(e) => updateCurrent({ commandOverride: e.target.value || undefined })} + /> +

{t("settings.cliAgents.commandHelp")}

+
+ +
+ + + updateCurrent({ + extraArgs: e.target.value.split(/\s+/).filter((s) => s.length > 0), + }) + } + /> +

{t("settings.cliAgents.extraArgsHelp")}

+
+ +
+ + + updateCurrent({ + envAdditions: e.target.value + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0), + }) + } + /> +

{t("settings.cliAgents.envHelp")}

+
+ +
+ + +

+ {approved[selectedId] + ? t("settings.cliAgents.approvedNote") + : t("settings.cliAgents.autonomyHelp")} +

+
+ + )} +
+ ); +} + export function SettingsModal({ onClose, addToast, @@ -2328,6 +2556,13 @@ export function SettingsModal({ const renderSectionFields = () => { switch (activeSection) { + case "cli-agents": + return ( + <> + {renderScopeBanner()} + + + ); case "general": return ( <> diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 2f97816815..6e18b95583 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -60,7 +60,23 @@ import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel"; import type { WorkflowFieldDefinition } from "../api"; import { CustomModelDropdown } from "./CustomModelDropdown"; -type ExecutorKind = "model" | "agent" | "skill" | "cli"; +type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent"; + +/** Adapter descriptor served by GET /api/cli-agents (U15). */ +interface CliAdapterDescriptorView { + id: string; + name: string; + tier: "native" | "hybrid" | "generic"; +} + +/** Static fallback so the picker renders before/without the API fetch. */ +const CLI_AGENT_ADAPTER_FALLBACK: CliAdapterDescriptorView[] = [ + { id: "claude-code", name: "Claude Code", tier: "native" }, + { id: "codex", name: "Codex", tier: "hybrid" }, + { id: "droid", name: "Droid", tier: "hybrid" }, + { id: "pi", name: "Pi", tier: "hybrid" }, + { id: "generic", name: "Generic CLI", tier: "generic" }, +]; // Mirror of @fusion/core's isBuiltinWorkflowId / BUILTIN_WORKFLOW_ID_PREFIX. // Inlined because the dashboard app build aliases "@fusion/core" to its @@ -551,9 +567,29 @@ function InnerEditor({ const [models, setModels] = useState([]); const [agents, setAgents] = useState([]); const [skills, setSkills] = useState([]); + // CLI-agent adapter catalog (U15). Falls back to the static list when the API + // fetch fails so the picker is always usable. + const [cliAdapters, setCliAdapters] = useState(CLI_AGENT_ADAPTER_FALLBACK); const currentExecutor = (selectedNode?.data.config?.executor as ExecutorKind | undefined) ?? "model"; + useEffect(() => { + if (currentExecutor !== "cli-agent") return; + let cancelled = false; + fetch("/api/cli-agents") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (cancelled || !data?.adapters) return; + setCliAdapters(data.adapters as CliAdapterDescriptorView[]); + }) + .catch(() => { + // Keep the static fallback; the picker stays functional. + }); + return () => { + cancelled = true; + }; + }, [currentExecutor]); + useEffect(() => { // step-review offers an optional review model picker (KTD-4). if (selectedNode?.data.kind === "step-review" && models.length === 0) { @@ -774,6 +810,7 @@ function InnerEditor({ + @@ -871,6 +908,83 @@ function InnerEditor({ )} + {currentExecutor === "cli-agent" && ( +
+ + + + {Boolean( + (selectedNode.data.config?.cliAutonomy as { autoApprove?: boolean } | undefined) + ?.autoApprove, + ) && ( +

+ {t("workflowEditor.cliAgent.autonomyNote")} +

+ )} + + +
+ )} +