feat: cli-agent adapter settings, autonomy approval gate, and node editor config (U15)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
32
.changeset/cli-agent-settings-autonomy-gate.md
Normal file
32
.changeset/cli-agent-settings-autonomy-gate.md
Normal file
@@ -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.
|
||||||
134
packages/core/src/__tests__/global-settings-cli-agents.test.ts
Normal file
134
packages/core/src/__tests__/global-settings-cli-agents.test.ts
Normal file
@@ -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"] } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ import { mkdir, readFile, writeFile, rename, chmod } from "node:fs/promises";
|
|||||||
import { existsSync, mkdirSync, renameSync } from "node:fs";
|
import { existsSync, mkdirSync, renameSync } from "node:fs";
|
||||||
import type { GlobalSettings } from "./types.js";
|
import type { GlobalSettings } from "./types.js";
|
||||||
import { DEFAULT_GLOBAL_SETTINGS } from "./types.js";
|
import { DEFAULT_GLOBAL_SETTINGS } from "./types.js";
|
||||||
|
import { sanitizeCliAgentsSettings } from "./settings-schema.js";
|
||||||
|
|
||||||
function getHomeDir(): string {
|
function getHomeDir(): string {
|
||||||
return process.env.HOME || process.env.USERPROFILE || homedir();
|
return process.env.HOME || process.env.USERPROFILE || homedir();
|
||||||
@@ -193,6 +194,11 @@ export class GlobalSettingsStore {
|
|||||||
// null → delete this key from the merged object
|
// null → delete this key from the merged object
|
||||||
// This effectively makes it fall through to the default
|
// This effectively makes it fall through to the default
|
||||||
delete merged[key];
|
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 {
|
} else {
|
||||||
// normal value → set it
|
// normal value → set it
|
||||||
merged[key] = value;
|
merged[key] = value;
|
||||||
|
|||||||
@@ -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 { 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 } 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 { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js";
|
||||||
export {
|
export {
|
||||||
resolveEntryPointBranchAssignment,
|
resolveEntryPointBranchAssignment,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { GlobalSettings, ProjectSettings, Settings } from "./types.js";
|
import type { CliAgentSettings, GlobalSettings, ProjectSettings, Settings } from "./types.js";
|
||||||
|
|
||||||
export interface MergeRequestContractShadowSettingsSource {
|
export interface MergeRequestContractShadowSettingsSource {
|
||||||
mergeRequestContractShadowEnabled?: boolean;
|
mergeRequestContractShadowEnabled?: boolean;
|
||||||
@@ -180,6 +180,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
|
|||||||
},
|
},
|
||||||
owningNodeHandoffPolicy: "reassign-to-local",
|
owningNodeHandoffPolicy: "reassign-to-local",
|
||||||
experimentalFeatures: {},
|
experimentalFeatures: {},
|
||||||
|
cliAgents: {},
|
||||||
} satisfies CompleteSettings<GlobalSettings>;
|
} satisfies CompleteSettings<GlobalSettings>;
|
||||||
|
|
||||||
/** Default values for project-level settings. */
|
/** Default values for project-level settings. */
|
||||||
@@ -188,6 +189,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
|||||||
globalPauseReason: undefined,
|
globalPauseReason: undefined,
|
||||||
defaultWorkflowId: undefined,
|
defaultWorkflowId: undefined,
|
||||||
approvedWorkflowCliCommands: undefined,
|
approvedWorkflowCliCommands: undefined,
|
||||||
|
approvedCliAutonomyAdapters: undefined,
|
||||||
enginePaused: false,
|
enginePaused: false,
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
maxTriageConcurrent: 2,
|
maxTriageConcurrent: 2,
|
||||||
@@ -521,3 +523,81 @@ export function resolvePersistAgentThinkingLog(
|
|||||||
if (typeof settings?.persistAgentThinkingLog === "boolean") return settings.persistAgentThinkingLog;
|
if (typeof settings?.persistAgentThinkingLog === "boolean") return settings.persistAgentThinkingLog;
|
||||||
return false;
|
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<string, unknown>;
|
||||||
|
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<string, CliAgentSettings> {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||||
|
const input = value as Record<string, unknown>;
|
||||||
|
const out: Record<string, CliAgentSettings> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12985,6 +12985,50 @@ ${stepsSection}`;
|
|||||||
} as unknown as Partial<Settings>);
|
} as unknown as Partial<Settings>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<Settings>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Revoke a previously-granted elevated-autonomy approval. Idempotent. */
|
||||||
|
async revokeCliAutonomy(adapterId: string): Promise<void> {
|
||||||
|
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<Settings>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List adapters approved for elevated autonomy in this project. */
|
||||||
|
async listApprovedCliAutonomyAdapters(): Promise<string[]> {
|
||||||
|
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. */
|
/** Read the workflow currently selected for a task, if any. */
|
||||||
/**
|
/**
|
||||||
* Synchronously resolve the parsed WorkflowIr that governs a task's columns
|
* Synchronously resolve the parsed WorkflowIr that governs a task's columns
|
||||||
|
|||||||
@@ -2942,6 +2942,39 @@ export interface GlobalSettings {
|
|||||||
*
|
*
|
||||||
* Default: {} (empty object — no experimental features enabled). */
|
* Default: {} (empty object — no experimental features enabled). */
|
||||||
experimentalFeatures?: Record<string, boolean>;
|
experimentalFeatures?: Record<string, boolean>;
|
||||||
|
/** 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<string, CliAgentSettings>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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";
|
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;
|
* (trust-on-first-use). A node's command must appear here before it runs;
|
||||||
* named scripts (settings.scripts) never require approval. */
|
* named scripts (settings.scripts) never require approval. */
|
||||||
approvedWorkflowCliCommands?: string[];
|
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
|
/** Engine pause (soft pause): when true, the scheduler and triage
|
||||||
* processor stop dispatching **new** work (scheduling, triage
|
* processor stop dispatching **new** work (scheduling, triage
|
||||||
* specification, and auto-merge), but currently running agent sessions
|
* specification, and auto-merge), but currently running agent sessions
|
||||||
@@ -3891,6 +3930,10 @@ export {
|
|||||||
isProjectSettingsKey,
|
isProjectSettingsKey,
|
||||||
isMergeRequestContractShadowEnabled,
|
isMergeRequestContractShadowEnabled,
|
||||||
resolvePersistAgentThinkingLog,
|
resolvePersistAgentThinkingLog,
|
||||||
|
sanitizeCliAgentSettings,
|
||||||
|
sanitizeCliAgentsSettings,
|
||||||
|
CLI_AGENT_ADAPTER_IDS,
|
||||||
|
CLI_AGENT_AUTONOMY_MODES,
|
||||||
} from "./settings-schema.js";
|
} from "./settings-schema.js";
|
||||||
|
|
||||||
export interface BoardConfig {
|
export interface BoardConfig {
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
|
|||||||
{ id: "notifications", label: "Notifications", labelKey: "settings.nav.notifications", scope: "global" },
|
{ id: "notifications", label: "Notifications", labelKey: "settings.nav.notifications", scope: "global" },
|
||||||
{ id: "node-sync", label: "Node Sync", labelKey: "settings.nav.nodeSync", 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: "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: "research-global", label: "Research Defaults", labelKey: "settings.nav.researchGlobal", scope: "global" },
|
||||||
{ id: "experimental", label: "Experimental Features", labelKey: "settings.nav.experimental", scope: "global" },
|
{ id: "experimental", label: "Experimental Features", labelKey: "settings.nav.experimental", scope: "global" },
|
||||||
{ id: "remote", label: "Remote Access", labelKey: "settings.nav.remote", scope: "global" },
|
{ id: "remote", label: "Remote Access", labelKey: "settings.nav.remote", scope: "global" },
|
||||||
@@ -422,6 +423,233 @@ interface SettingsModalProps {
|
|||||||
onOpenApprovals?: (approvalId?: string) => void;
|
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<CliAdapterDescriptorView[]>([]);
|
||||||
|
const [settings, setSettings] = useState<Record<string, CliAgentSettingsEntry>>({});
|
||||||
|
const [approved, setApproved] = useState<Record<string, boolean>>({});
|
||||||
|
const [selectedId, setSelectedId] = useState<string>("");
|
||||||
|
|
||||||
|
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<string, CliAgentSettingsEntry>);
|
||||||
|
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<string, CliAgentSettingsEntry>);
|
||||||
|
} catch (err) {
|
||||||
|
addToast(getErrorMessage(err) || t("settings.cliAgents.saveFailed"), "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addToast, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateCurrent = useCallback(
|
||||||
|
(patch: Partial<CliAgentSettingsEntry>) => {
|
||||||
|
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 (
|
||||||
|
<div data-testid="cli-agents-settings">
|
||||||
|
<h4 className="settings-section-heading">{t("settings.cliAgents.heading")}</h4>
|
||||||
|
<p className="settings-section-description">{t("settings.cliAgents.description")}</p>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="cliAgentAdapter">{t("settings.cliAgents.adapterLabel")}</label>
|
||||||
|
<select
|
||||||
|
id="cliAgentAdapter"
|
||||||
|
value={selectedId}
|
||||||
|
onChange={(e) => setSelectedId(e.target.value)}
|
||||||
|
>
|
||||||
|
{adapters.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.name} ({t(`settings.cliAgents.tier.${a.tier}`)})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedId && (
|
||||||
|
<>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="cliAgentCommand">{t("settings.cliAgents.commandLabel")}</label>
|
||||||
|
<input
|
||||||
|
id="cliAgentCommand"
|
||||||
|
type="text"
|
||||||
|
placeholder={
|
||||||
|
adapters.find((a) => a.id === selectedId)?.defaultCommand ?? ""
|
||||||
|
}
|
||||||
|
value={current.commandOverride ?? ""}
|
||||||
|
onChange={(e) => updateCurrent({ commandOverride: e.target.value || undefined })}
|
||||||
|
/>
|
||||||
|
<p className="settings-field-help">{t("settings.cliAgents.commandHelp")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="cliAgentExtraArgs">{t("settings.cliAgents.extraArgsLabel")}</label>
|
||||||
|
<input
|
||||||
|
id="cliAgentExtraArgs"
|
||||||
|
type="text"
|
||||||
|
value={(current.extraArgs ?? []).join(" ")}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateCurrent({
|
||||||
|
extraArgs: e.target.value.split(/\s+/).filter((s) => s.length > 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="settings-field-help">{t("settings.cliAgents.extraArgsHelp")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="cliAgentEnv">{t("settings.cliAgents.envLabel")}</label>
|
||||||
|
<input
|
||||||
|
id="cliAgentEnv"
|
||||||
|
type="text"
|
||||||
|
value={(current.envAdditions ?? []).join(", ")}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateCurrent({
|
||||||
|
envAdditions: e.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="settings-field-help">{t("settings.cliAgents.envHelp")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="cliAgentAutonomy">{t("settings.cliAgents.autonomyLabel")}</label>
|
||||||
|
<select
|
||||||
|
id="cliAgentAutonomy"
|
||||||
|
value={current.autonomyMode ?? "default"}
|
||||||
|
onChange={(e) => void onAutonomyChange(e.target.value as "default" | "elevated")}
|
||||||
|
>
|
||||||
|
<option value="default">{t("settings.cliAgents.autonomy.default")}</option>
|
||||||
|
<option value="elevated">{t("settings.cliAgents.autonomy.elevated")}</option>
|
||||||
|
</select>
|
||||||
|
<p className="settings-field-help">
|
||||||
|
{approved[selectedId]
|
||||||
|
? t("settings.cliAgents.approvedNote")
|
||||||
|
: t("settings.cliAgents.autonomyHelp")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsModal({
|
export function SettingsModal({
|
||||||
onClose,
|
onClose,
|
||||||
addToast,
|
addToast,
|
||||||
@@ -2328,6 +2556,13 @@ export function SettingsModal({
|
|||||||
|
|
||||||
const renderSectionFields = () => {
|
const renderSectionFields = () => {
|
||||||
switch (activeSection) {
|
switch (activeSection) {
|
||||||
|
case "cli-agents":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderScopeBanner()}
|
||||||
|
<CliAgentsSettingsSection projectId={projectId} addToast={addToast} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
case "general":
|
case "general":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -60,7 +60,23 @@ import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel";
|
|||||||
import type { WorkflowFieldDefinition } from "../api";
|
import type { WorkflowFieldDefinition } from "../api";
|
||||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
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.
|
// Mirror of @fusion/core's isBuiltinWorkflowId / BUILTIN_WORKFLOW_ID_PREFIX.
|
||||||
// Inlined because the dashboard app build aliases "@fusion/core" to its
|
// Inlined because the dashboard app build aliases "@fusion/core" to its
|
||||||
@@ -551,9 +567,29 @@ function InnerEditor({
|
|||||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||||
const [agents, setAgents] = useState<Agent[]>([]);
|
const [agents, setAgents] = useState<Agent[]>([]);
|
||||||
const [skills, setSkills] = useState<DiscoveredSkill[]>([]);
|
const [skills, setSkills] = useState<DiscoveredSkill[]>([]);
|
||||||
|
// 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<CliAdapterDescriptorView[]>(CLI_AGENT_ADAPTER_FALLBACK);
|
||||||
|
|
||||||
const currentExecutor = (selectedNode?.data.config?.executor as ExecutorKind | undefined) ?? "model";
|
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(() => {
|
useEffect(() => {
|
||||||
// step-review offers an optional review model picker (KTD-4).
|
// step-review offers an optional review model picker (KTD-4).
|
||||||
if (selectedNode?.data.kind === "step-review" && models.length === 0) {
|
if (selectedNode?.data.kind === "step-review" && models.length === 0) {
|
||||||
@@ -774,6 +810,7 @@ function InnerEditor({
|
|||||||
<option value="agent">Agent</option>
|
<option value="agent">Agent</option>
|
||||||
<option value="skill">Skill</option>
|
<option value="skill">Skill</option>
|
||||||
<option value="cli">CLI / script</option>
|
<option value="cli">CLI / script</option>
|
||||||
|
<option value="cli-agent">{t("workflowEditor.cliAgent.executorOption")}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -871,6 +908,83 @@ function InnerEditor({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{currentExecutor === "cli-agent" && (
|
||||||
|
<div data-testid="cli-agent-config">
|
||||||
|
<label className="wf-field">
|
||||||
|
<span>{t("workflowEditor.cliAgent.adapterLabel")}</span>
|
||||||
|
<select
|
||||||
|
data-testid="cli-agent-adapter"
|
||||||
|
value={String(selectedNode.data.config?.cliAdapterId ?? "")}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSelectedData({ config: { cliAdapterId: e.target.value || undefined } })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">{t("workflowEditor.cliAgent.adapterPlaceholder")}</option>
|
||||||
|
{cliAdapters.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.name} ({t(`workflowEditor.cliAgent.tier.${a.tier}`)})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="wf-inspector-note">
|
||||||
|
{t("workflowEditor.cliAgent.adapterNote")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="wf-field wf-field--checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="cli-agent-autonomy"
|
||||||
|
checked={Boolean(
|
||||||
|
(selectedNode.data.config?.cliAutonomy as { autoApprove?: boolean } | undefined)
|
||||||
|
?.autoApprove,
|
||||||
|
)}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSelectedData({
|
||||||
|
config: {
|
||||||
|
cliAutonomy: {
|
||||||
|
...((selectedNode.data.config?.cliAutonomy as Record<string, unknown>) ?? {}),
|
||||||
|
autoApprove: e.target.checked,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>{t("workflowEditor.cliAgent.autonomyLabel")}</span>
|
||||||
|
</label>
|
||||||
|
{Boolean(
|
||||||
|
(selectedNode.data.config?.cliAutonomy as { autoApprove?: boolean } | undefined)
|
||||||
|
?.autoApprove,
|
||||||
|
) && (
|
||||||
|
<p className="wf-inspector-note wf-inspector-note--info">
|
||||||
|
{t("workflowEditor.cliAgent.autonomyNote")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="wf-field">
|
||||||
|
<span>{t("workflowEditor.cliAgent.notifyLabel")}</span>
|
||||||
|
<select
|
||||||
|
data-testid="cli-agent-notify"
|
||||||
|
value={String(
|
||||||
|
(selectedNode.data.config?.cliNotify as { mode?: string } | undefined)?.mode ??
|
||||||
|
"banner",
|
||||||
|
)}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateSelectedData({ config: { cliNotify: { mode: e.target.value } } })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="banner">{t("workflowEditor.cliAgent.notify.banner")}</option>
|
||||||
|
<option value="banner+notify">
|
||||||
|
{t("workflowEditor.cliAgent.notify.bannerNotify")}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<span className="wf-inspector-note">
|
||||||
|
{t("workflowEditor.cliAgent.notifyNote")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<label className="wf-field wf-field--checkbox">
|
<label className="wf-field wf-field--checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen, waitFor, cleanup, fireEvent } from "@testing-library/react";
|
||||||
|
import type { WorkflowDefinition } from "@fusion/core";
|
||||||
|
|
||||||
|
vi.mock("../../api", () => ({
|
||||||
|
fetchWorkflows: vi.fn(),
|
||||||
|
createWorkflow: vi.fn(),
|
||||||
|
updateWorkflow: vi.fn(),
|
||||||
|
deleteWorkflow: vi.fn(),
|
||||||
|
compileWorkflow: vi.fn(),
|
||||||
|
fetchTraits: vi.fn(),
|
||||||
|
fetchStepParsers: vi.fn(),
|
||||||
|
fetchModels: vi.fn(),
|
||||||
|
fetchAgents: vi.fn(),
|
||||||
|
fetchDiscoveredSkills: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchWorkflows,
|
||||||
|
fetchTraits,
|
||||||
|
fetchStepParsers,
|
||||||
|
updateWorkflow,
|
||||||
|
fetchModels,
|
||||||
|
} from "../../api";
|
||||||
|
import type { TraitCatalogEntry } from "../../api";
|
||||||
|
import { WorkflowNodeEditor } from "../WorkflowNodeEditor";
|
||||||
|
|
||||||
|
const TRAIT_CATALOG: TraitCatalogEntry[] = [
|
||||||
|
{ id: "intake", name: "Intake", builtin: true, flags: { intake: true } },
|
||||||
|
{ id: "complete", name: "Complete", builtin: true, flags: { complete: true } },
|
||||||
|
];
|
||||||
|
|
||||||
|
function promptDef(): WorkflowDefinition {
|
||||||
|
return {
|
||||||
|
id: "WF-CLI",
|
||||||
|
name: "CLI",
|
||||||
|
description: "",
|
||||||
|
ir: {
|
||||||
|
version: "v2",
|
||||||
|
name: "CLI",
|
||||||
|
columns: [
|
||||||
|
{ id: "triage", name: "Triage", traits: [{ trait: "intake" }] },
|
||||||
|
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
|
||||||
|
],
|
||||||
|
nodes: [
|
||||||
|
{ id: "start", kind: "start", column: "triage" },
|
||||||
|
{ id: "step", kind: "prompt", column: "triage", config: { prompt: "do" } },
|
||||||
|
{ id: "end", kind: "end", column: "done" },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: "start", to: "step", condition: "success" },
|
||||||
|
{ from: "step", to: "end", condition: "success" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
start: { x: 0, y: 20 },
|
||||||
|
step: { x: 120, y: 60 },
|
||||||
|
end: { x: 360, y: 240 },
|
||||||
|
},
|
||||||
|
createdAt: "2026-06-03T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-06-03T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WorkflowNodeEditor — cli-agent executor (U15)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(fetchWorkflows).mockResolvedValue([promptDef()]);
|
||||||
|
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
|
||||||
|
vi.mocked(fetchStepParsers).mockResolvedValue([]);
|
||||||
|
vi.mocked(fetchModels).mockResolvedValue({ models: [] });
|
||||||
|
vi.mocked(updateWorkflow).mockResolvedValue(promptDef());
|
||||||
|
// Stub the adapter-catalog fetch.
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (url: string) => {
|
||||||
|
if (typeof url === "string" && url.startsWith("/api/cli-agents")) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
adapters: [
|
||||||
|
{ id: "claude-code", name: "Claude Code", tier: "native" },
|
||||||
|
{ id: "generic", name: "Generic CLI", tier: "generic" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
return { ok: false, json: async () => ({}) } as Response;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function selectCliAgent() {
|
||||||
|
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||||
|
const node = await screen.findByTestId("wf-node-prompt");
|
||||||
|
fireEvent.click(node);
|
||||||
|
const executorSel = (await screen.findByText("Executor")).parentElement!.querySelector(
|
||||||
|
"select",
|
||||||
|
)! as HTMLSelectElement;
|
||||||
|
fireEvent.change(executorSel, { target: { value: "cli-agent" } });
|
||||||
|
return executorSel;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("surfaces adapter + notification fields when cli-agent is selected", async () => {
|
||||||
|
await selectCliAgent();
|
||||||
|
expect(await screen.findByTestId("cli-agent-config")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("cli-agent-adapter")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("cli-agent-notify")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("cli-agent-autonomy")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("populates the adapter picker with tier labels from the API", async () => {
|
||||||
|
await selectCliAgent();
|
||||||
|
const adapterSel = (await screen.findByTestId("cli-agent-adapter")) as HTMLSelectElement;
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(adapterSel.querySelectorAll("option").length).toBeGreaterThan(2);
|
||||||
|
});
|
||||||
|
const optionText = Array.from(adapterSel.querySelectorAll("option")).map((o) => o.textContent);
|
||||||
|
expect(optionText.some((t) => t?.includes("Claude Code") && t.includes("native"))).toBe(true);
|
||||||
|
expect(optionText.some((t) => t?.includes("Generic CLI") && t.includes("generic"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lands the selected adapter + notify config in the node config", async () => {
|
||||||
|
await selectCliAgent();
|
||||||
|
const adapterSel = (await screen.findByTestId("cli-agent-adapter")) as HTMLSelectElement;
|
||||||
|
fireEvent.change(adapterSel, { target: { value: "claude-code" } });
|
||||||
|
expect(adapterSel.value).toBe("claude-code");
|
||||||
|
|
||||||
|
const notifySel = screen.getByTestId("cli-agent-notify") as HTMLSelectElement;
|
||||||
|
fireEvent.change(notifySel, { target: { value: "banner+notify" } });
|
||||||
|
expect(notifySel.value).toBe("banner+notify");
|
||||||
|
|
||||||
|
// Save and assert the persisted IR carries the cli-agent node config.
|
||||||
|
fireEvent.click(screen.getByText("Save").closest("button")!);
|
||||||
|
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
|
||||||
|
const savedIr = vi.mocked(updateWorkflow).mock.calls.at(-1)![1] as {
|
||||||
|
ir: { nodes: Array<{ id: string; config?: Record<string, unknown> }> };
|
||||||
|
};
|
||||||
|
const stepNode = savedIr.ir.nodes.find((n) => n.id === "step")!;
|
||||||
|
expect(stepNode.config?.executor).toBe("cli-agent");
|
||||||
|
expect(stepNode.config?.cliAdapterId).toBe("claude-code");
|
||||||
|
expect(stepNode.config?.cliNotify).toEqual({ mode: "banner+notify" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -173,6 +173,7 @@ import { registerFnBinaryRoutes } from "./routes/register-fn-binary-routes.js";
|
|||||||
import { registerUpdateCheckRoutes } from "./routes/register-update-check-routes.js";
|
import { registerUpdateCheckRoutes } from "./routes/register-update-check-routes.js";
|
||||||
import { registerDiagnosticsRoutes } from "./routes/register-diagnostics-routes.js";
|
import { registerDiagnosticsRoutes } from "./routes/register-diagnostics-routes.js";
|
||||||
import { registerCliAgentHooksRoute } from "./routes/cli-agent-hooks.js";
|
import { registerCliAgentHooksRoute } from "./routes/cli-agent-hooks.js";
|
||||||
|
import { registerCliAgentSettingsRoutes } from "./routes/cli-agent-settings.js";
|
||||||
import { registerIntegratedRouters, registerIntegratedDevServerRouter } from "./routes/register-integrated-routers.js";
|
import { registerIntegratedRouters, registerIntegratedDevServerRouter } from "./routes/register-integrated-routers.js";
|
||||||
import { registerApprovalRoutes } from "./routes/register-approval-routes.js";
|
import { registerApprovalRoutes } from "./routes/register-approval-routes.js";
|
||||||
import { registerWorktrunkRoutes } from "./routes/register-worktrunk-routes.js";
|
import { registerWorktrunkRoutes } from "./routes/register-worktrunk-routes.js";
|
||||||
@@ -1957,6 +1958,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
// the daemon bearer-token middleware (hook scripts only hold the session token).
|
// the daemon bearer-token middleware (hook scripts only hold the session token).
|
||||||
registerCliAgentHooksRoute(routeContext);
|
registerCliAgentHooksRoute(routeContext);
|
||||||
|
|
||||||
|
// CLI Agent Executor adapter settings + autonomy approval (U15) — daemon-token
|
||||||
|
// authed like the rest of /api (the approving principal is the token holder).
|
||||||
|
registerCliAgentSettingsRoutes(routeContext);
|
||||||
|
|
||||||
// ── Automation / Scheduled Task Routes ────────────────────────────
|
// ── Automation / Scheduled Task Routes ────────────────────────────
|
||||||
//
|
//
|
||||||
// Scope-aware endpoints: Accept `scope=global|project` query param or body field.
|
// Scope-aware endpoints: Accept `scope=global|project` query param or body field.
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
import express from "express";
|
||||||
|
import { Router } from "express";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { request as performRequest } from "../../test-request.js";
|
||||||
|
import { rethrowAsApiError } from "../../api-error.js";
|
||||||
|
import { registerCliAgentSettingsRoutes } from "../cli-agent-settings.js";
|
||||||
|
import type { ApiRoutesContext } from "../types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal fake TaskStore covering the methods the route touches. Global settings
|
||||||
|
* (`cliAgents`) and project autonomy approvals live in-memory; `getSettings`
|
||||||
|
* returns the merged view (global ∪ project) the route reads from.
|
||||||
|
*/
|
||||||
|
function makeFakeStore() {
|
||||||
|
const state = {
|
||||||
|
cliAgents: {} as Record<string, unknown>,
|
||||||
|
approvedCliAutonomyAdapters: [] as string[],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
async getSettings() {
|
||||||
|
return {
|
||||||
|
cliAgents: state.cliAgents,
|
||||||
|
approvedCliAutonomyAdapters: [...state.approvedCliAutonomyAdapters],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async updateGlobalSettings(patch: { cliAgents?: Record<string, unknown> }) {
|
||||||
|
if (patch.cliAgents) state.cliAgents = patch.cliAgents;
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
async isCliAutonomyApproved(adapterId: string) {
|
||||||
|
return state.approvedCliAutonomyAdapters.includes(adapterId);
|
||||||
|
},
|
||||||
|
async approveCliAutonomy(adapterId: string) {
|
||||||
|
if (!state.approvedCliAutonomyAdapters.includes(adapterId)) {
|
||||||
|
state.approvedCliAutonomyAdapters.push(adapterId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async revokeCliAutonomy(adapterId: string) {
|
||||||
|
state.approvedCliAutonomyAdapters = state.approvedCliAutonomyAdapters.filter(
|
||||||
|
(a) => a !== adapterId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount(store: ReturnType<typeof makeFakeStore>) {
|
||||||
|
const router = Router();
|
||||||
|
router.use(express.json());
|
||||||
|
const ctx = {
|
||||||
|
router,
|
||||||
|
rethrowAsApiError,
|
||||||
|
getScopedStore: async () => store as never,
|
||||||
|
getProjectContext: async () => ({ store: store as never, engine: undefined, projectId: "p1" }),
|
||||||
|
} as unknown as ApiRoutesContext;
|
||||||
|
registerCliAgentSettingsRoutes(ctx);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use("/api", router);
|
||||||
|
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||||
|
res.status(err?.statusCode ?? err?.status ?? 500).json({ error: err?.message ?? String(err) });
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
const JSON_HEADERS = { "content-type": "application/json", host: "127.0.0.1" };
|
||||||
|
|
||||||
|
describe("cli-agent-settings routes (U15)", () => {
|
||||||
|
let store: ReturnType<typeof makeFakeStore>;
|
||||||
|
let app: express.Express;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = makeFakeStore();
|
||||||
|
app = mount(store);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {});
|
||||||
|
|
||||||
|
it("GET /api/cli-agents lists adapter descriptors with tier labels", async () => {
|
||||||
|
const res = await performRequest(app, "GET", "/api/cli-agents", undefined, JSON_HEADERS);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const ids = res.body.adapters.map((a: { id: string }) => a.id);
|
||||||
|
expect(ids).toContain("claude-code");
|
||||||
|
expect(ids).toContain("generic");
|
||||||
|
const claude = res.body.adapters.find((a: { id: string }) => a.id === "claude-code");
|
||||||
|
expect(claude.tier).toBe("native");
|
||||||
|
const generic = res.body.adapters.find((a: { id: string }) => a.id === "generic");
|
||||||
|
expect(generic.tier).toBe("generic");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PUT /api/cli-agents/settings persists a sanitized adapter config", async () => {
|
||||||
|
const res = await performRequest(
|
||||||
|
app,
|
||||||
|
"PUT",
|
||||||
|
"/api/cli-agents/settings",
|
||||||
|
JSON.stringify({
|
||||||
|
adapterId: "codex",
|
||||||
|
config: { extraArgs: ["--model=gpt"], autonomyMode: "garbage", bogus: 1 },
|
||||||
|
}),
|
||||||
|
JSON_HEADERS,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// autonomyMode "garbage" + bogus field dropped at the core write boundary.
|
||||||
|
expect(res.body.cliAgents).toEqual({ codex: { extraArgs: ["--model=gpt"] } });
|
||||||
|
expect(store.state.cliAgents).toEqual({ codex: { extraArgs: ["--model=gpt"] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PUT rejects an unknown adapter id", async () => {
|
||||||
|
const res = await performRequest(
|
||||||
|
app,
|
||||||
|
"PUT",
|
||||||
|
"/api/cli-agents/settings",
|
||||||
|
JSON.stringify({ adapterId: "evil", config: {} }),
|
||||||
|
JSON_HEADERS,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("autonomy approval round-trip (approve requires confirm)", async () => {
|
||||||
|
// Initially unapproved.
|
||||||
|
let res = await performRequest(app, "GET", "/api/cli-agents/claude-code/autonomy", undefined, JSON_HEADERS);
|
||||||
|
expect(res.body).toEqual({ adapterId: "claude-code", approved: false });
|
||||||
|
|
||||||
|
// Approve without confirm → rejected.
|
||||||
|
res = await performRequest(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/cli-agents/claude-code/approve-autonomy",
|
||||||
|
JSON.stringify({}),
|
||||||
|
JSON_HEADERS,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
|
||||||
|
// Approve with confirm → granted.
|
||||||
|
res = await performRequest(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/cli-agents/claude-code/approve-autonomy",
|
||||||
|
JSON.stringify({ confirm: true }),
|
||||||
|
JSON_HEADERS,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(store.state.approvedCliAutonomyAdapters).toContain("claude-code");
|
||||||
|
|
||||||
|
// Now reads as approved.
|
||||||
|
res = await performRequest(app, "GET", "/api/cli-agents/claude-code/autonomy", undefined, JSON_HEADERS);
|
||||||
|
expect(res.body.approved).toBe(true);
|
||||||
|
|
||||||
|
// Revoke.
|
||||||
|
res = await performRequest(
|
||||||
|
app,
|
||||||
|
"POST",
|
||||||
|
"/api/cli-agents/claude-code/revoke-autonomy",
|
||||||
|
JSON.stringify({}),
|
||||||
|
JSON_HEADERS,
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(store.state.approvedCliAutonomyAdapters).not.toContain("claude-code");
|
||||||
|
});
|
||||||
|
});
|
||||||
131
packages/dashboard/src/routes/cli-agent-settings.ts
Normal file
131
packages/dashboard/src/routes/cli-agent-settings.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* CLI-agent adapter settings + autonomy-approval routes (CLI Agent Executor, U15).
|
||||||
|
*
|
||||||
|
* All routes are daemon-token authed (the standard `/api` middleware — no new
|
||||||
|
* auth surface; the approving principal in v1 is the daemon-token holder, the
|
||||||
|
* single workspace owner). Routes:
|
||||||
|
*
|
||||||
|
* GET /api/cli-agents — adapter descriptors (tier +
|
||||||
|
* capability flags) for the
|
||||||
|
* settings UI + node editor.
|
||||||
|
* GET /api/cli-agents/settings — per-adapter launch config
|
||||||
|
* (GlobalSettings.cliAgents).
|
||||||
|
* PUT /api/cli-agents/settings — replace one adapter's launch
|
||||||
|
* config (validated at the core
|
||||||
|
* write boundary).
|
||||||
|
* GET /api/cli-agents/:adapterId/autonomy — approval state for the project.
|
||||||
|
* POST /api/cli-agents/:adapterId/approve-autonomy — approve elevated autonomy
|
||||||
|
* for the adapter in this
|
||||||
|
* project (idempotent).
|
||||||
|
* POST /api/cli-agents/:adapterId/revoke-autonomy — revoke approval.
|
||||||
|
*
|
||||||
|
* The approval is per-PROJECT + per-adapter, stored in project settings
|
||||||
|
* (`approvedCliAutonomyAdapters`) and mirrors the raw workflow-CLI-command
|
||||||
|
* approval precedent (`approveWorkflowCliCommand`).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { listCliAdapterDescriptors } from "@fusion/engine";
|
||||||
|
import { sanitizeCliAgentSettings, CLI_AGENT_ADAPTER_IDS } from "@fusion/core";
|
||||||
|
import { ApiError, badRequest } from "../api-error.js";
|
||||||
|
import type { ApiRoutesContext } from "./types.js";
|
||||||
|
|
||||||
|
/** Static adapter descriptor list (tier + capability flags). Stable per build. */
|
||||||
|
const ADAPTER_DESCRIPTORS = listCliAdapterDescriptors();
|
||||||
|
const KNOWN_ADAPTER_IDS = new Set<string>(CLI_AGENT_ADAPTER_IDS);
|
||||||
|
|
||||||
|
export function registerCliAgentSettingsRoutes(ctx: ApiRoutesContext): void {
|
||||||
|
const { router, rethrowAsApiError } = ctx;
|
||||||
|
|
||||||
|
// GET /api/cli-agents — adapter catalog (tier labels + capability flags).
|
||||||
|
router.get("/cli-agents", async (_req, res) => {
|
||||||
|
res.json({ adapters: ADAPTER_DESCRIPTORS });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/cli-agents/settings — the per-adapter launch config map.
|
||||||
|
router.get("/cli-agents/settings", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const store = await ctx.getScopedStore(req);
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
res.json({ cliAgents: (settings as { cliAgents?: unknown }).cliAgents ?? {} });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof ApiError) throw err;
|
||||||
|
rethrowAsApiError(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/cli-agents/settings — replace ONE adapter's launch config. The body
|
||||||
|
// is `{ adapterId, config }`; an empty/invalid config clears the entry. The
|
||||||
|
// core write boundary sanitizes (`sanitizeCliAgentsSettings`) so invalid fields
|
||||||
|
// are dropped regardless — this route just scopes the merge to one adapter.
|
||||||
|
router.put("/cli-agents/settings", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const store = await ctx.getScopedStore(req);
|
||||||
|
const adapterId = String((req.body as { adapterId?: unknown })?.adapterId ?? "").trim();
|
||||||
|
if (!adapterId || !KNOWN_ADAPTER_IDS.has(adapterId)) {
|
||||||
|
throw badRequest("Unknown or missing adapterId");
|
||||||
|
}
|
||||||
|
const rawConfig = (req.body as { config?: unknown })?.config;
|
||||||
|
const sanitized = sanitizeCliAgentSettings(rawConfig);
|
||||||
|
|
||||||
|
const settings = await store.getSettings();
|
||||||
|
const prior = { ...(((settings as { cliAgents?: Record<string, unknown> }).cliAgents) ?? {}) };
|
||||||
|
if (sanitized) {
|
||||||
|
prior[adapterId] = sanitized;
|
||||||
|
} else {
|
||||||
|
delete prior[adapterId];
|
||||||
|
}
|
||||||
|
await store.updateGlobalSettings({ cliAgents: prior } as never);
|
||||||
|
res.json({ cliAgents: prior });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof ApiError) throw err;
|
||||||
|
rethrowAsApiError(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/cli-agents/:adapterId/autonomy — approval state for this project.
|
||||||
|
router.get("/cli-agents/:adapterId/autonomy", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { store } = await ctx.getProjectContext(req);
|
||||||
|
const adapterId = req.params.adapterId;
|
||||||
|
if (!KNOWN_ADAPTER_IDS.has(adapterId)) throw badRequest("Unknown adapterId");
|
||||||
|
const approved = await store.isCliAutonomyApproved(adapterId);
|
||||||
|
res.json({ adapterId, approved });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof ApiError) throw err;
|
||||||
|
rethrowAsApiError(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/cli-agents/:adapterId/approve-autonomy — grant elevated autonomy
|
||||||
|
// for the adapter in this project. Idempotent. Requires an explicit confirm
|
||||||
|
// flag in the body so a stray POST cannot grant elevation by accident.
|
||||||
|
router.post("/cli-agents/:adapterId/approve-autonomy", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { store } = await ctx.getProjectContext(req);
|
||||||
|
const adapterId = req.params.adapterId;
|
||||||
|
if (!KNOWN_ADAPTER_IDS.has(adapterId)) throw badRequest("Unknown adapterId");
|
||||||
|
if ((req.body as { confirm?: unknown })?.confirm !== true) {
|
||||||
|
throw badRequest("Elevated autonomy approval requires explicit confirmation (confirm: true)");
|
||||||
|
}
|
||||||
|
await store.approveCliAutonomy(adapterId);
|
||||||
|
res.json({ adapterId, approved: true });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof ApiError) throw err;
|
||||||
|
rethrowAsApiError(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/cli-agents/:adapterId/revoke-autonomy — revoke approval. Idempotent.
|
||||||
|
router.post("/cli-agents/:adapterId/revoke-autonomy", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { store } = await ctx.getProjectContext(req);
|
||||||
|
const adapterId = req.params.adapterId;
|
||||||
|
if (!KNOWN_ADAPTER_IDS.has(adapterId)) throw badRequest("Unknown adapterId");
|
||||||
|
await store.revokeCliAutonomy(adapterId);
|
||||||
|
res.json({ adapterId, approved: false });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof ApiError) throw err;
|
||||||
|
rethrowAsApiError(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
182
packages/engine/src/cli-agent/__tests__/autonomy.test.ts
Normal file
182
packages/engine/src/cli-agent/__tests__/autonomy.test.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* Autonomy elevation detection + approval gate (U15).
|
||||||
|
*
|
||||||
|
* Central invariant under test: the posture chip / gate derive from the FULLY
|
||||||
|
* RESOLVED argv + env, so elevation smuggled through ANY channel (autonomy
|
||||||
|
* field, extra args, env additions, command override) trips the gate — not just
|
||||||
|
* the autonomy field.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
resolveEffectivePosture,
|
||||||
|
assertAutonomyApproved,
|
||||||
|
CliAutonomyNotApprovedError,
|
||||||
|
type CliAgentResolveSettings,
|
||||||
|
} from "../autonomy.js";
|
||||||
|
import { claudeCodeAdapter } from "../adapters/claude-code.js";
|
||||||
|
import { codexAdapter } from "../adapters/codex.js";
|
||||||
|
import { genericCliAdapter } from "../adapters/generic.js";
|
||||||
|
|
||||||
|
describe("resolveEffectivePosture", () => {
|
||||||
|
it("returns default (no elevation) for a baseline launch", () => {
|
||||||
|
const posture = resolveEffectivePosture({ adapter: claudeCodeAdapter });
|
||||||
|
expect(posture.elevated).toBe(false);
|
||||||
|
expect(posture.mode).toBe("default");
|
||||||
|
expect(posture.flags).toEqual([]);
|
||||||
|
expect(posture.adapterId).toBe("claude-code");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects elevation from the autonomy field (autoApprove)", () => {
|
||||||
|
const posture = resolveEffectivePosture({
|
||||||
|
adapter: claudeCodeAdapter,
|
||||||
|
nodeConfig: { cliAutonomy: { autoApprove: true } },
|
||||||
|
});
|
||||||
|
expect(posture.elevated).toBe(true);
|
||||||
|
// Claude's buildLaunch emits --dangerously-skip-permissions for autoApprove,
|
||||||
|
// so it surfaces as an argv-channel marker (not merely the autonomy field).
|
||||||
|
expect(posture.flags.some((f) => f.channel === "args")).toBe(true);
|
||||||
|
expect(
|
||||||
|
posture.flags.some((f) => f.marker === "--dangerously-skip-permissions"),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("BYPASS CLOSURE: --dangerously-skip-permissions via extraArgs (not the field) trips elevation", () => {
|
||||||
|
const settings: CliAgentResolveSettings = {
|
||||||
|
extraArgs: ["--dangerously-skip-permissions"],
|
||||||
|
};
|
||||||
|
const posture = resolveEffectivePosture({ adapter: claudeCodeAdapter, settings });
|
||||||
|
expect(posture.elevated).toBe(true);
|
||||||
|
expect(
|
||||||
|
posture.flags.some(
|
||||||
|
(f) => f.channel === "args" && f.marker === "--dangerously-skip-permissions",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects codex -c approval_policy override smuggled via extraArgs", () => {
|
||||||
|
const posture = resolveEffectivePosture({
|
||||||
|
adapter: codexAdapter,
|
||||||
|
settings: { extraArgs: ["-c", "approval_policy=never"] },
|
||||||
|
});
|
||||||
|
expect(posture.elevated).toBe(true);
|
||||||
|
expect(posture.flags.some((f) => f.marker.includes("approval_policy"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects autonomy-toggling env var via envAdditions", () => {
|
||||||
|
const posture = resolveEffectivePosture({
|
||||||
|
adapter: claudeCodeAdapter,
|
||||||
|
settings: { envAdditions: ["SOME_TOOL_SKIP_PERMISSIONS"] },
|
||||||
|
});
|
||||||
|
expect(posture.elevated).toBe(true);
|
||||||
|
expect(
|
||||||
|
posture.flags.some(
|
||||||
|
(f) => f.channel === "env" && f.marker === "SOME_TOOL_SKIP_PERMISSIONS",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT flag a benign env addition", () => {
|
||||||
|
const posture = resolveEffectivePosture({
|
||||||
|
adapter: claudeCodeAdapter,
|
||||||
|
settings: { envAdditions: ["HTTP_PROXY", "NO_COLOR"] },
|
||||||
|
});
|
||||||
|
expect(posture.elevated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a non-default command override as privileged", () => {
|
||||||
|
const posture = resolveEffectivePosture({
|
||||||
|
adapter: claudeCodeAdapter,
|
||||||
|
settings: { commandOverride: "/tmp/evil-claude" },
|
||||||
|
});
|
||||||
|
expect(posture.elevated).toBe(true);
|
||||||
|
expect(
|
||||||
|
posture.flags.some((f) => f.channel === "command" && f.marker === "/tmp/evil-claude"),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT flag a command override equal to the adapter default", () => {
|
||||||
|
const posture = resolveEffectivePosture({
|
||||||
|
adapter: claudeCodeAdapter,
|
||||||
|
settings: { commandOverride: claudeCodeAdapter.defaultCommand },
|
||||||
|
});
|
||||||
|
expect(posture.elevated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps autonomyMode:elevated onto the posture even with no field", () => {
|
||||||
|
const posture = resolveEffectivePosture({
|
||||||
|
adapter: claudeCodeAdapter,
|
||||||
|
settings: { autonomyMode: "elevated" },
|
||||||
|
});
|
||||||
|
expect(posture.elevated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags generic-tier bypass args (heuristic patterns)", () => {
|
||||||
|
const posture = resolveEffectivePosture({
|
||||||
|
adapter: genericCliAdapter,
|
||||||
|
settings: { commandOverride: "mytool", extraArgs: ["--auto-approve-everything"] },
|
||||||
|
});
|
||||||
|
expect(posture.elevated).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("assertAutonomyApproved (gate)", () => {
|
||||||
|
const elevated: CliAgentResolveSettings = {
|
||||||
|
extraArgs: ["--dangerously-skip-permissions"],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("permits a non-elevated launch without consulting approval", async () => {
|
||||||
|
let consulted = false;
|
||||||
|
const posture = await assertAutonomyApproved({
|
||||||
|
adapter: claudeCodeAdapter,
|
||||||
|
projectId: "p1",
|
||||||
|
isApproved: () => {
|
||||||
|
consulted = true;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(posture.elevated).toBe(false);
|
||||||
|
expect(consulted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws a typed error when elevated and unapproved", async () => {
|
||||||
|
await expect(
|
||||||
|
assertAutonomyApproved({
|
||||||
|
adapter: claudeCodeAdapter,
|
||||||
|
settings: elevated,
|
||||||
|
projectId: "p1",
|
||||||
|
isApproved: () => false,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(CliAutonomyNotApprovedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes the offending flags + scope on the error", async () => {
|
||||||
|
let err: unknown;
|
||||||
|
try {
|
||||||
|
await assertAutonomyApproved({
|
||||||
|
adapter: claudeCodeAdapter,
|
||||||
|
settings: elevated,
|
||||||
|
projectId: "proj-x",
|
||||||
|
isApproved: () => false,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
err = e;
|
||||||
|
}
|
||||||
|
expect(err).toBeInstanceOf(CliAutonomyNotApprovedError);
|
||||||
|
const typed = err as CliAutonomyNotApprovedError;
|
||||||
|
expect(typed.code).toBe("CLI_AUTONOMY_NOT_APPROVED");
|
||||||
|
expect(typed.projectId).toBe("proj-x");
|
||||||
|
expect(typed.adapterId).toBe("claude-code");
|
||||||
|
expect(typed.flags.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("permits an elevated launch once the project has approved", async () => {
|
||||||
|
const posture = await assertAutonomyApproved({
|
||||||
|
adapter: claudeCodeAdapter,
|
||||||
|
settings: elevated,
|
||||||
|
projectId: "p1",
|
||||||
|
isApproved: async ({ projectId, adapterId }) =>
|
||||||
|
projectId === "p1" && adapterId === "claude-code",
|
||||||
|
});
|
||||||
|
expect(posture.elevated).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -129,6 +129,33 @@ export type CliTelemetryWiring = (ctx: {
|
|||||||
|
|
||||||
// ── The adapter interface ─────────────────────────────────────────────────
|
// ── The adapter interface ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-adapter declaration of the argument markers that signify *elevated*
|
||||||
|
* (bypass-permissions / full-auto) autonomy (CLI Agent Executor, U15). The
|
||||||
|
* autonomy elevation detector scans the FULLY RESOLVED argv for these so an
|
||||||
|
* elevation smuggled through extra-args (not the autonomy field) is still
|
||||||
|
* caught. Generic env-pattern detection is shared across all adapters and lives
|
||||||
|
* in `autonomy.ts`; this only declares the adapter-specific argv side.
|
||||||
|
*/
|
||||||
|
export interface CliAdapterElevationMarkers {
|
||||||
|
/**
|
||||||
|
* Exact-match argv tokens that always denote elevation (e.g.
|
||||||
|
* `--dangerously-skip-permissions`).
|
||||||
|
*/
|
||||||
|
readonly exactArgs?: readonly string[];
|
||||||
|
/**
|
||||||
|
* Regexes tested against each resolved argv token (e.g. Codex's
|
||||||
|
* `-c approval_policy=...` override, droid `--auto high`). A match denotes
|
||||||
|
* elevation. Authors keep these conservative — false positives gate launches.
|
||||||
|
*/
|
||||||
|
readonly argPatterns?: readonly RegExp[];
|
||||||
|
/**
|
||||||
|
* Optional predicate over the whole resolved argv for multi-token markers
|
||||||
|
* (e.g. `--auto` followed by `high`). Returns the matched token(s) to report.
|
||||||
|
*/
|
||||||
|
readonly matchArgv?: (argv: readonly string[]) => string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface CliAgentAdapter {
|
export interface CliAgentAdapter {
|
||||||
/** Stable identifier (e.g. "claude-code", "codex", "generic"). */
|
/** Stable identifier (e.g. "claude-code", "codex", "generic"). */
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
@@ -136,6 +163,17 @@ export interface CliAgentAdapter {
|
|||||||
readonly name: string;
|
readonly name: string;
|
||||||
/** Capability flags — read honestly by the pipeline and UI. */
|
/** Capability flags — read honestly by the pipeline and UI. */
|
||||||
readonly capabilities: CliAdapterCapabilities;
|
readonly capabilities: CliAdapterCapabilities;
|
||||||
|
/**
|
||||||
|
* Default binary the adapter invokes when no command override is set (U15).
|
||||||
|
* Surfaced so the elevation detector can treat a *different* command override
|
||||||
|
* as privileged without reaching into the adapter's private constants.
|
||||||
|
*/
|
||||||
|
readonly defaultCommand?: string;
|
||||||
|
/**
|
||||||
|
* Adapter-specific elevated-autonomy argv markers (U15). When omitted the
|
||||||
|
* detector relies on the shared generic env-pattern set only.
|
||||||
|
*/
|
||||||
|
readonly elevationMarkers?: CliAdapterElevationMarkers;
|
||||||
|
|
||||||
/** Build the launch command/args from settings + autonomy posture. */
|
/** Build the launch command/args from settings + autonomy posture. */
|
||||||
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec;
|
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec;
|
||||||
|
|||||||
@@ -539,6 +539,22 @@ export const claudeCodeAdapter: CliAgentAdapter = {
|
|||||||
id: "claude-code",
|
id: "claude-code",
|
||||||
name: "Claude Code",
|
name: "Claude Code",
|
||||||
capabilities: CLAUDE_CODE_CAPABILITIES,
|
capabilities: CLAUDE_CODE_CAPABILITIES,
|
||||||
|
defaultCommand: DEFAULT_COMMAND,
|
||||||
|
elevationMarkers: {
|
||||||
|
// Claude Code bypass: `--dangerously-skip-permissions` and the
|
||||||
|
// `--permission-mode bypassPermissions` form.
|
||||||
|
exactArgs: ["--dangerously-skip-permissions"],
|
||||||
|
argPatterns: [/^--permission-mode(=|$)/, /bypassPermissions/i],
|
||||||
|
matchArgv(argv) {
|
||||||
|
const hits: string[] = [];
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
if (argv[i] === "--permission-mode" && argv[i + 1] === "bypassPermissions") {
|
||||||
|
hits.push("--permission-mode bypassPermissions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec {
|
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec {
|
||||||
const settings = readSettings(ctx);
|
const settings = readSettings(ctx);
|
||||||
|
|||||||
@@ -579,6 +579,35 @@ export const codexAdapter: CliAgentAdapter = {
|
|||||||
id: "codex",
|
id: "codex",
|
||||||
name: "Codex",
|
name: "Codex",
|
||||||
capabilities: CODEX_CAPABILITIES,
|
capabilities: CODEX_CAPABILITIES,
|
||||||
|
defaultCommand: DEFAULT_COMMAND,
|
||||||
|
elevationMarkers: {
|
||||||
|
// Codex elevation: the sandbox/approval bypass flag, `--full-auto`/`--yolo`
|
||||||
|
// shorthands, and `-c approval_policy=...` / `-c sandbox=...` config overrides.
|
||||||
|
exactArgs: [
|
||||||
|
"--dangerously-bypass-approvals-and-sandbox",
|
||||||
|
"--full-auto",
|
||||||
|
"--yolo",
|
||||||
|
],
|
||||||
|
argPatterns: [
|
||||||
|
/^-c\s*approval_policy=/i,
|
||||||
|
/^approval_policy=/i,
|
||||||
|
/^-c\s*sandbox(_mode)?=/i,
|
||||||
|
/^sandbox(_mode)?=/i,
|
||||||
|
],
|
||||||
|
matchArgv(argv) {
|
||||||
|
const hits: string[] = [];
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
// `-c approval_policy=...` (or sandbox=...) passed as two tokens.
|
||||||
|
if (argv[i] === "-c" && typeof argv[i + 1] === "string") {
|
||||||
|
const v = argv[i + 1];
|
||||||
|
if (/^(approval_policy|sandbox|sandbox_mode)=/i.test(v)) {
|
||||||
|
hits.push(`-c ${v}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec {
|
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec {
|
||||||
const settings = readSettings(ctx);
|
const settings = readSettings(ctx);
|
||||||
|
|||||||
@@ -471,6 +471,23 @@ export const droidAdapter: CliAgentAdapter = {
|
|||||||
id: "droid",
|
id: "droid",
|
||||||
name: "Droid",
|
name: "Droid",
|
||||||
capabilities: DROID_CAPABILITIES,
|
capabilities: DROID_CAPABILITIES,
|
||||||
|
defaultCommand: DEFAULT_COMMAND,
|
||||||
|
elevationMarkers: {
|
||||||
|
// Droid elevation: `--skip-permissions-unsafe` and `--auto <high|...>` full
|
||||||
|
// autonomy levels (per `droid --help`). `--auto low` is NOT treated as
|
||||||
|
// elevation; only `high`/`medium` levels bypass meaningful approvals.
|
||||||
|
exactArgs: ["--skip-permissions-unsafe"],
|
||||||
|
argPatterns: [/^--auto=(high|medium)$/i],
|
||||||
|
matchArgv(argv) {
|
||||||
|
const hits: string[] = [];
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
if (argv[i] === "--auto" && /^(high|medium)$/i.test(argv[i + 1] ?? "")) {
|
||||||
|
hits.push(`--auto ${argv[i + 1]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec {
|
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec {
|
||||||
const settings = readSettings(ctx);
|
const settings = readSettings(ctx);
|
||||||
|
|||||||
@@ -347,6 +347,19 @@ export class GenericCliAdapter implements CliAgentAdapter {
|
|||||||
readonly id = "generic";
|
readonly id = "generic";
|
||||||
readonly name = "Generic CLI";
|
readonly name = "Generic CLI";
|
||||||
readonly capabilities = GENERIC_CAPABILITIES;
|
readonly capabilities = GENERIC_CAPABILITIES;
|
||||||
|
// The generic tier has no native autonomy concept, but common bypass flags
|
||||||
|
// smuggled through args/extraArgs are still caught so the posture chip is
|
||||||
|
// honest. The shared generic env-pattern detector applies on top of this.
|
||||||
|
readonly elevationMarkers = {
|
||||||
|
argPatterns: [
|
||||||
|
/dangerous/i,
|
||||||
|
/skip[-_]permissions?/i,
|
||||||
|
/bypass[-_](approvals?|permissions?|sandbox)/i,
|
||||||
|
/^--yolo$/i,
|
||||||
|
/^--full-auto$/i,
|
||||||
|
/auto[-_]approve/i,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec {
|
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec {
|
||||||
const settings = ctx.settings as GenericAdapterSettings & { command?: string };
|
const settings = ctx.settings as GenericAdapterSettings & { command?: string };
|
||||||
|
|||||||
58
packages/engine/src/cli-agent/adapters/index.ts
Normal file
58
packages/engine/src/cli-agent/adapters/index.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Bundled CLI-agent adapters barrel (U15).
|
||||||
|
*
|
||||||
|
* Re-exports the shipped adapters and a small static capability/tier descriptor
|
||||||
|
* list surfaces (settings UI + node editor) read to render adapter pickers with
|
||||||
|
* honest tier labels — without each surface reaching into per-adapter modules.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { tierForCapabilities, type CliAdapterTier } from "../autonomy.js";
|
||||||
|
import type { CliAgentAdapter } from "../adapter.js";
|
||||||
|
import { claudeCodeAdapter } from "./claude-code.js";
|
||||||
|
import { codexAdapter } from "./codex.js";
|
||||||
|
import { droidAdapter } from "./droid.js";
|
||||||
|
import { piAdapter } from "./pi.js";
|
||||||
|
import { genericCliAdapter } from "./generic.js";
|
||||||
|
|
||||||
|
export { claudeCodeAdapter, codexAdapter, droidAdapter, piAdapter, genericCliAdapter };
|
||||||
|
|
||||||
|
/** All shipped adapters in display order (native → hybrid → generic). */
|
||||||
|
export const BUNDLED_CLI_ADAPTERS: readonly CliAgentAdapter[] = Object.freeze([
|
||||||
|
claudeCodeAdapter,
|
||||||
|
codexAdapter,
|
||||||
|
droidAdapter,
|
||||||
|
piAdapter,
|
||||||
|
genericCliAdapter,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** A UI-facing descriptor for one adapter: id, name, tier, and capability flags. */
|
||||||
|
export interface CliAdapterDescriptor {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tier: CliAdapterTier;
|
||||||
|
defaultCommand: string | null;
|
||||||
|
capabilities: {
|
||||||
|
nativeDone: boolean;
|
||||||
|
nativeWaiting: boolean;
|
||||||
|
transcriptSource: string;
|
||||||
|
supportsResume: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the descriptor list the dashboard serves to settings/node-editor UIs. */
|
||||||
|
export function listCliAdapterDescriptors(
|
||||||
|
adapters: readonly CliAgentAdapter[] = BUNDLED_CLI_ADAPTERS,
|
||||||
|
): CliAdapterDescriptor[] {
|
||||||
|
return adapters.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
tier: tierForCapabilities(a.capabilities),
|
||||||
|
defaultCommand: a.defaultCommand ?? null,
|
||||||
|
capabilities: {
|
||||||
|
nativeDone: a.capabilities.nativeDone,
|
||||||
|
nativeWaiting: a.capabilities.nativeWaiting,
|
||||||
|
transcriptSource: a.capabilities.transcriptSource,
|
||||||
|
supportsResume: a.capabilities.supportsResume,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -400,6 +400,24 @@ export const piAdapter: CliAgentAdapter = {
|
|||||||
id: "pi",
|
id: "pi",
|
||||||
name: "Pi",
|
name: "Pi",
|
||||||
capabilities: PI_CAPABILITIES,
|
capabilities: PI_CAPABILITIES,
|
||||||
|
defaultCommand: DEFAULT_COMMAND,
|
||||||
|
elevationMarkers: {
|
||||||
|
// Pi elevation: widening the tool allowlist to include write-capable tools
|
||||||
|
// without per-tool confirmation (`--tools read,bash,edit,write`) or a yolo /
|
||||||
|
// no-confirm flag. The `--tools` form with bash/edit/write is the auto-approve
|
||||||
|
// equivalent the posture maps to.
|
||||||
|
exactArgs: ["--yolo", "--no-confirm", "--dangerously-skip-permissions"],
|
||||||
|
argPatterns: [/^--tools=.*\b(bash|edit|write)\b/i],
|
||||||
|
matchArgv(argv) {
|
||||||
|
const hits: string[] = [];
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
if (argv[i] === "--tools" && /\b(bash|edit|write)\b/i.test(argv[i + 1] ?? "")) {
|
||||||
|
hits.push(`--tools ${argv[i + 1]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec {
|
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec {
|
||||||
const settings = readSettings(ctx);
|
const settings = readSettings(ctx);
|
||||||
|
|||||||
316
packages/engine/src/cli-agent/autonomy.ts
Normal file
316
packages/engine/src/cli-agent/autonomy.ts
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
/**
|
||||||
|
* CLI-agent autonomy posture resolution + elevation approval gate (U15).
|
||||||
|
*
|
||||||
|
* The autonomy approval gate's central invariant: **elevation expressed through
|
||||||
|
* ANY channel must be caught.** Operators can request bypass-permissions-style
|
||||||
|
* autonomy through four distinct settings channels:
|
||||||
|
*
|
||||||
|
* 1. the autonomy field (`nodeConfig.cliAutonomy.autoApprove`, or the
|
||||||
|
* settings `autonomyMode: "elevated"`),
|
||||||
|
* 2. extra args (`--dangerously-skip-permissions` smuggled in),
|
||||||
|
* 3. env additions (`*_DANGEROUS*` / `*_SKIP_PERMISSIONS*` vars),
|
||||||
|
* 4. a command override (pointing the binary at an arbitrary path).
|
||||||
|
*
|
||||||
|
* A posture chip derived from the autonomy *field alone* would be false-safe
|
||||||
|
* when elevation rides one of the other channels. So `resolveEffectivePosture`
|
||||||
|
* derives the posture from the **fully resolved argv + env** — it asks the
|
||||||
|
* adapter to build the actual launch invocation (which already folds in the
|
||||||
|
* posture flags, extra args, and command override), then scans that argv plus
|
||||||
|
* the env additions against the adapter's declared elevation markers and a
|
||||||
|
* shared generic env-pattern set. A non-default command override is itself
|
||||||
|
* privileged.
|
||||||
|
*
|
||||||
|
* Any resolved elevation requires a stored per-project approval (mirroring the
|
||||||
|
* raw workflow-CLI-command approval precedent). `assertAutonomyApproved` throws
|
||||||
|
* a typed `CliAutonomyNotApprovedError` when elevation is present but no
|
||||||
|
* approval is recorded for the project — the caller surfaces this as a launch
|
||||||
|
* failure, never a stall.
|
||||||
|
*
|
||||||
|
* Pure engine policy: no HTTP, no DB. The approval lookup is injected as a
|
||||||
|
* small async predicate so the dashboard can back it with the project store.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CliAutonomyPosture } from "@fusion/core";
|
||||||
|
import type {
|
||||||
|
CliAgentAdapter,
|
||||||
|
CliAdapterLaunchSettings,
|
||||||
|
} from "./adapter.js";
|
||||||
|
|
||||||
|
// ── Adapter capability tiers (U15 node-editor labels) ───────────────────────
|
||||||
|
|
||||||
|
/** UI tier label derived honestly from an adapter's capability flags. */
|
||||||
|
export type CliAdapterTier = "native" | "hybrid" | "generic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the tier label the node editor renders (native / hybrid / generic) from
|
||||||
|
* an adapter's honest capability flags:
|
||||||
|
* - native: a native done signal AND a structured transcript (hooks/jsonl/…).
|
||||||
|
* - hybrid: SOME native signal (done or waiting) but a weaker transcript story.
|
||||||
|
* - generic: no native signals at all (the heuristic tier).
|
||||||
|
*/
|
||||||
|
export function tierForCapabilities(caps: {
|
||||||
|
nativeDone: boolean;
|
||||||
|
nativeWaiting: boolean;
|
||||||
|
transcriptSource: string;
|
||||||
|
}): CliAdapterTier {
|
||||||
|
if (caps.nativeDone && caps.transcriptSource !== "none") return "native";
|
||||||
|
if (caps.nativeDone || caps.nativeWaiting) return "hybrid";
|
||||||
|
return "generic";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings shape (mirrors @fusion/core CliAgentSettings) ──────────────────
|
||||||
|
|
||||||
|
/** Per-adapter operator launch settings consumed by posture resolution. */
|
||||||
|
export interface CliAgentResolveSettings {
|
||||||
|
commandOverride?: string;
|
||||||
|
extraArgs?: readonly string[];
|
||||||
|
autonomyMode?: "default" | "elevated";
|
||||||
|
envAdditions?: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cli-agent slice of a workflow node's config relevant to posture. */
|
||||||
|
export interface CliAgentNodeConfig {
|
||||||
|
cliAutonomy?: CliAutonomyPosture | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Effective posture ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A single resolved elevation marker, for chip rendering + audit. */
|
||||||
|
export interface CliElevationFlag {
|
||||||
|
/** Which settings channel surfaced the elevation. */
|
||||||
|
channel: "autonomy" | "args" | "env" | "command";
|
||||||
|
/** The concrete marker (argv token, env var name, or command path). */
|
||||||
|
marker: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The effective autonomy posture for a launch, derived from the resolved argv +
|
||||||
|
* env (NOT the autonomy field alone). This is what the posture chip renders and
|
||||||
|
* what is denormalized onto the session record at spawn.
|
||||||
|
*/
|
||||||
|
export interface EffectivePosture {
|
||||||
|
/** Adapter the posture was resolved against. */
|
||||||
|
adapterId: string;
|
||||||
|
/** Coarse mode: `elevated` iff any elevation marker was detected. */
|
||||||
|
mode: "default" | "elevated";
|
||||||
|
/** Whether the resolved invocation is elevated through any channel. */
|
||||||
|
elevated: boolean;
|
||||||
|
/** Every detected elevation marker (across all channels). */
|
||||||
|
flags: CliElevationFlag[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generic env-pattern detection (shared across all adapters) ──────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Env-var name patterns that toggle autonomy/permission bypass for SOME CLI.
|
||||||
|
* Applied to every adapter's env additions regardless of adapter-declared
|
||||||
|
* markers — a bypass-toggling env var is elevation no matter which CLI reads it.
|
||||||
|
*/
|
||||||
|
export const GENERIC_ELEVATION_ENV_PATTERNS: readonly RegExp[] = Object.freeze([
|
||||||
|
/_DANGEROUS/i,
|
||||||
|
/DANGEROUS_/i,
|
||||||
|
/SKIP_PERMISSIONS?/i,
|
||||||
|
/BYPASS_(APPROVALS?|PERMISSIONS?|SANDBOX)/i,
|
||||||
|
/AUTO_APPROVE/i,
|
||||||
|
/YOLO/i,
|
||||||
|
/FULL_AUTO/i,
|
||||||
|
]);
|
||||||
|
|
||||||
|
function envIsElevating(name: string): boolean {
|
||||||
|
return GENERIC_ELEVATION_ENV_PATTERNS.some((re) => re.test(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Posture resolution ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ResolveEffectivePostureArgs {
|
||||||
|
/** The adapter the session will be driven by. */
|
||||||
|
adapter: CliAgentAdapter;
|
||||||
|
/** Per-adapter operator settings (from GlobalSettings.cliAgents). */
|
||||||
|
settings?: CliAgentResolveSettings | null;
|
||||||
|
/** The cli-agent node config (carries the autonomy field). */
|
||||||
|
nodeConfig?: CliAgentNodeConfig | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the adapter launch settings the manager would use, folding the operator
|
||||||
|
* settings + the autonomy field into the shape `buildLaunch` consumes. Keeping
|
||||||
|
* this here (not in the manager) lets posture resolution scan the EXACT argv the
|
||||||
|
* child would receive without spawning.
|
||||||
|
*/
|
||||||
|
function buildLaunchSettings(
|
||||||
|
settings: CliAgentResolveSettings | null | undefined,
|
||||||
|
): CliAdapterLaunchSettings {
|
||||||
|
const out: CliAdapterLaunchSettings = {};
|
||||||
|
if (settings?.commandOverride) out.command = settings.commandOverride;
|
||||||
|
if (settings?.extraArgs && settings.extraArgs.length > 0) {
|
||||||
|
out.extraArgs = [...settings.extraArgs];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map the resolved autonomy intent onto the posture the adapter's `buildLaunch`
|
||||||
|
* keys off (it reads `posture.autoApprove`). Elevation intent comes from EITHER
|
||||||
|
* the node autonomy field OR `autonomyMode: "elevated"`.
|
||||||
|
*/
|
||||||
|
function resolveIntentPosture(
|
||||||
|
settings: CliAgentResolveSettings | null | undefined,
|
||||||
|
nodeConfig: CliAgentNodeConfig | null | undefined,
|
||||||
|
): { posture: CliAutonomyPosture | null; fromField: boolean } {
|
||||||
|
const fieldAutoApprove = nodeConfig?.cliAutonomy?.autoApprove === true;
|
||||||
|
const modeElevated = settings?.autonomyMode === "elevated";
|
||||||
|
const autoApprove = fieldAutoApprove || modeElevated;
|
||||||
|
const base = nodeConfig?.cliAutonomy ?? null;
|
||||||
|
if (!autoApprove) {
|
||||||
|
return { posture: base, fromField: false };
|
||||||
|
}
|
||||||
|
return { posture: { ...(base ?? {}), autoApprove: true }, fromField: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a token matches the adapter's declared argv elevation markers. */
|
||||||
|
function argvElevationHits(
|
||||||
|
adapter: CliAgentAdapter,
|
||||||
|
argv: readonly string[],
|
||||||
|
): string[] {
|
||||||
|
const markers = adapter.elevationMarkers;
|
||||||
|
if (!markers) return [];
|
||||||
|
const hits = new Set<string>();
|
||||||
|
const exact = new Set(markers.exactArgs ?? []);
|
||||||
|
for (const tok of argv) {
|
||||||
|
if (exact.has(tok)) hits.add(tok);
|
||||||
|
for (const re of markers.argPatterns ?? []) {
|
||||||
|
if (re.test(tok)) {
|
||||||
|
hits.add(tok);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const m of markers.matchArgv?.(argv) ?? []) hits.add(m);
|
||||||
|
return [...hits];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the effective autonomy posture from the fully resolved argv + env.
|
||||||
|
* Pure; never throws. The gate decision is made by `assertAutonomyApproved`.
|
||||||
|
*/
|
||||||
|
export function resolveEffectivePosture(
|
||||||
|
args: ResolveEffectivePostureArgs,
|
||||||
|
): EffectivePosture {
|
||||||
|
const { adapter } = args;
|
||||||
|
const settings = args.settings ?? null;
|
||||||
|
const nodeConfig = args.nodeConfig ?? null;
|
||||||
|
|
||||||
|
const { posture } = resolveIntentPosture(settings, nodeConfig);
|
||||||
|
const launchSettings = buildLaunchSettings(settings);
|
||||||
|
|
||||||
|
// Build the EXACT argv the child would receive (folds posture flags + extra
|
||||||
|
// args + command override). A buildLaunch failure (e.g. generic with no
|
||||||
|
// command) degrades to scanning the operator-supplied channels directly.
|
||||||
|
let resolvedArgv: string[] = [];
|
||||||
|
let resolvedCommand: string | undefined;
|
||||||
|
try {
|
||||||
|
const spec = adapter.buildLaunch({ settings: launchSettings, posture });
|
||||||
|
resolvedArgv = spec.args;
|
||||||
|
resolvedCommand = spec.command;
|
||||||
|
} catch {
|
||||||
|
resolvedArgv = [...(settings?.extraArgs ?? [])];
|
||||||
|
resolvedCommand = settings?.commandOverride;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flags: CliElevationFlag[] = [];
|
||||||
|
|
||||||
|
// Channel: argv (covers the autonomy field AND extra args — both land in argv).
|
||||||
|
for (const marker of argvElevationHits(adapter, resolvedArgv)) {
|
||||||
|
flags.push({ channel: "args", marker });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel: command override to a non-default path is privileged.
|
||||||
|
if (
|
||||||
|
typeof resolvedCommand === "string" &&
|
||||||
|
settings?.commandOverride &&
|
||||||
|
typeof adapter.defaultCommand === "string" &&
|
||||||
|
resolvedCommand !== adapter.defaultCommand
|
||||||
|
) {
|
||||||
|
flags.push({ channel: "command", marker: resolvedCommand });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel: env additions that toggle autonomy/bypass.
|
||||||
|
for (const name of settings?.envAdditions ?? []) {
|
||||||
|
if (envIsElevating(name)) flags.push({ channel: "env", marker: name });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If elevation was requested ONLY via the field but the adapter emitted no
|
||||||
|
// recognizable argv marker (e.g. an adapter that elevates with no flag), still
|
||||||
|
// record the intent so the gate is never bypassed by an unmarked adapter.
|
||||||
|
const intentElevated =
|
||||||
|
nodeConfig?.cliAutonomy?.autoApprove === true ||
|
||||||
|
settings?.autonomyMode === "elevated";
|
||||||
|
if (intentElevated && flags.length === 0) {
|
||||||
|
flags.push({ channel: "autonomy", marker: "autoApprove" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const elevated = flags.length > 0;
|
||||||
|
return {
|
||||||
|
adapterId: adapter.id,
|
||||||
|
mode: elevated ? "elevated" : "default",
|
||||||
|
elevated,
|
||||||
|
flags,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Approval gate ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Typed launch error: elevation requested without a stored project approval. */
|
||||||
|
export class CliAutonomyNotApprovedError extends Error {
|
||||||
|
readonly code = "CLI_AUTONOMY_NOT_APPROVED";
|
||||||
|
constructor(
|
||||||
|
public readonly adapterId: string,
|
||||||
|
public readonly projectId: string,
|
||||||
|
public readonly flags: CliElevationFlag[],
|
||||||
|
) {
|
||||||
|
const markers = flags.map((f) => `${f.channel}:${f.marker}`).join(", ");
|
||||||
|
super(
|
||||||
|
`Elevated CLI autonomy for adapter "${adapterId}" requires approval for ` +
|
||||||
|
`project "${projectId}" before launch (unapproved elevation: ${markers})`,
|
||||||
|
);
|
||||||
|
this.name = "CliAutonomyNotApprovedError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Predicate the dashboard backs with the project store's approval list. */
|
||||||
|
export type AutonomyApprovalLookup = (args: {
|
||||||
|
projectId: string;
|
||||||
|
adapterId: string;
|
||||||
|
}) => boolean | Promise<boolean>;
|
||||||
|
|
||||||
|
export interface AssertAutonomyApprovedArgs extends ResolveEffectivePostureArgs {
|
||||||
|
/** Project the launch belongs to (approvals are per-project). */
|
||||||
|
projectId: string;
|
||||||
|
/** Whether the project has approved elevated autonomy for this adapter. */
|
||||||
|
isApproved: AutonomyApprovalLookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the effective posture and enforce the approval gate. Returns the
|
||||||
|
* resolved posture (to denormalize onto the session record) when the launch is
|
||||||
|
* permitted. Throws `CliAutonomyNotApprovedError` when the resolved posture is
|
||||||
|
* elevated and no per-project approval is recorded.
|
||||||
|
*/
|
||||||
|
export async function assertAutonomyApproved(
|
||||||
|
args: AssertAutonomyApprovedArgs,
|
||||||
|
): Promise<EffectivePosture> {
|
||||||
|
const posture = resolveEffectivePosture(args);
|
||||||
|
if (!posture.elevated) return posture;
|
||||||
|
const approved = await args.isApproved({
|
||||||
|
projectId: args.projectId,
|
||||||
|
adapterId: args.adapter.id,
|
||||||
|
});
|
||||||
|
if (!approved) {
|
||||||
|
throw new CliAutonomyNotApprovedError(
|
||||||
|
args.adapter.id,
|
||||||
|
args.projectId,
|
||||||
|
posture.flags,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return posture;
|
||||||
|
}
|
||||||
@@ -58,6 +58,12 @@ import {
|
|||||||
writeSessionHookScripts,
|
writeSessionHookScripts,
|
||||||
cleanupSessionHookDir,
|
cleanupSessionHookDir,
|
||||||
} from "./hook-scripts.js";
|
} from "./hook-scripts.js";
|
||||||
|
import {
|
||||||
|
assertAutonomyApproved,
|
||||||
|
type AutonomyApprovalLookup,
|
||||||
|
type CliAgentResolveSettings,
|
||||||
|
type EffectivePosture,
|
||||||
|
} from "./autonomy.js";
|
||||||
|
|
||||||
// ── Outcome ──────────────────────────────────────────────────────────────────
|
// ── Outcome ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -108,6 +114,12 @@ export interface ResolvedCliExecutorConfig {
|
|||||||
cliNotify?: Record<string, unknown> | null;
|
cliNotify?: Record<string, unknown> | null;
|
||||||
/** Adapter launch settings (model, command override, extra args, …). */
|
/** Adapter launch settings (model, command override, extra args, …). */
|
||||||
settings?: Record<string, unknown>;
|
settings?: Record<string, unknown>;
|
||||||
|
/**
|
||||||
|
* Per-adapter operator launch config (U15), resolved from
|
||||||
|
* `GlobalSettings.cliAgents[adapterId]`. Drives elevation detection + the
|
||||||
|
* approval gate, and is folded into the adapter launch settings at spawn.
|
||||||
|
*/
|
||||||
|
cliAgentSettings?: CliAgentResolveSettings | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Launch options ─────────────────────────────────────────────────────────────
|
// ── Launch options ─────────────────────────────────────────────────────────────
|
||||||
@@ -140,6 +152,13 @@ export interface LaunchCliTaskSessionOptions {
|
|||||||
* dir; production callers may scope it under the engine's runtime dir.
|
* dir; production callers may scope it under the engine's runtime dir.
|
||||||
*/
|
*/
|
||||||
hookDirRoot?: string;
|
hookDirRoot?: string;
|
||||||
|
/**
|
||||||
|
* Per-project autonomy-approval lookup (U15). Backs the elevation approval
|
||||||
|
* gate: when the resolved effective posture is elevated and this returns false,
|
||||||
|
* launch fails with a typed `CliAutonomyNotApprovedError` (never a stall).
|
||||||
|
* When omitted, an elevated posture fails closed (treated as unapproved).
|
||||||
|
*/
|
||||||
|
isAutonomyApproved?: AutonomyApprovalLookup;
|
||||||
/**
|
/**
|
||||||
* Optional logger for lifecycle breadcrumbs. Best-effort; never throws.
|
* Optional logger for lifecycle breadcrumbs. Best-effort; never throws.
|
||||||
*/
|
*/
|
||||||
@@ -205,6 +224,19 @@ export class CliTaskSession {
|
|||||||
const log = opts.log ?? (() => {});
|
const log = opts.log ?? (() => {});
|
||||||
const adapter = opts.registry.get(opts.config.cliAdapterId);
|
const adapter = opts.registry.get(opts.config.cliAdapterId);
|
||||||
|
|
||||||
|
// 0. Autonomy approval gate (U15). Resolve the EFFECTIVE posture from the
|
||||||
|
// fully resolved argv + env (NOT the autonomy field alone) and enforce the
|
||||||
|
// per-project approval for any elevation. A missing lookup fails closed.
|
||||||
|
// Runs BEFORE any side effects (scratch dir / spawn) so an unapproved
|
||||||
|
// elevation never reserves a concurrency slot or leaves a scratch dir.
|
||||||
|
const effectivePosture: EffectivePosture = await assertAutonomyApproved({
|
||||||
|
adapter,
|
||||||
|
settings: opts.config.cliAgentSettings ?? null,
|
||||||
|
nodeConfig: { cliAutonomy: opts.config.cliAutonomy ?? null },
|
||||||
|
projectId: opts.projectId,
|
||||||
|
isApproved: opts.isAutonomyApproved ?? (() => false),
|
||||||
|
});
|
||||||
|
|
||||||
// 1. Scratch dir for the session-scoped hook scripts + settings.
|
// 1. Scratch dir for the session-scoped hook scripts + settings.
|
||||||
const root = opts.hookDirRoot ?? tmpdir();
|
const root = opts.hookDirRoot ?? tmpdir();
|
||||||
const hookDir = await mkdtemp(join(root, "fusion-cli-hooks-"));
|
const hookDir = await mkdtemp(join(root, "fusion-cli-hooks-"));
|
||||||
@@ -219,11 +251,33 @@ export class CliTaskSession {
|
|||||||
const hookScriptPath = join(hookDir, HOOK_SCRIPT_NAMES.hook);
|
const hookScriptPath = join(hookDir, HOOK_SCRIPT_NAMES.hook);
|
||||||
const settingsPath = join(hookDir, "settings.json");
|
const settingsPath = join(hookDir, "settings.json");
|
||||||
|
|
||||||
|
// Fold the per-adapter operator settings (U15) into the launch settings bag
|
||||||
|
// so they actually reach the child: command override → `command`, extra args
|
||||||
|
// → `extraArgs`, env additions → `envAllowlist`. Service credentials are
|
||||||
|
// ALWAYS excluded from the env allowlist regardless of what the operator
|
||||||
|
// added (a user must never widen the allowlist to leak FUSION_* creds).
|
||||||
|
const cliAgentSettings = opts.config.cliAgentSettings ?? null;
|
||||||
|
const operatorEnvAdditions = (cliAgentSettings?.envAdditions ?? []).filter(
|
||||||
|
(k) => !/^FUSION_/i.test(k),
|
||||||
|
);
|
||||||
|
const priorAllowlist = Array.isArray(
|
||||||
|
(opts.config.settings as Record<string, unknown> | undefined)?.envAllowlist,
|
||||||
|
)
|
||||||
|
? ((opts.config.settings as Record<string, unknown>).envAllowlist as string[])
|
||||||
|
: [];
|
||||||
|
|
||||||
// Build adapter launch settings carrying the hook-script refs. Claude's
|
// Build adapter launch settings carrying the hook-script refs. Claude's
|
||||||
// settings flow reads `hookScripts` + `settingsPath` off ctx.settings; other
|
// settings flow reads `hookScripts` + `settingsPath` off ctx.settings; other
|
||||||
// adapters ignore unknown keys.
|
// adapters ignore unknown keys.
|
||||||
const settings: Record<string, unknown> = {
|
const settings: Record<string, unknown> = {
|
||||||
...(opts.config.settings ?? {}),
|
...(opts.config.settings ?? {}),
|
||||||
|
...(cliAgentSettings?.commandOverride
|
||||||
|
? { command: cliAgentSettings.commandOverride }
|
||||||
|
: {}),
|
||||||
|
...(cliAgentSettings?.extraArgs && cliAgentSettings.extraArgs.length > 0
|
||||||
|
? { extraArgs: [...cliAgentSettings.extraArgs] }
|
||||||
|
: {}),
|
||||||
|
envAllowlist: [...new Set([...priorAllowlist, ...operatorEnvAdditions])],
|
||||||
hookScripts: {
|
hookScripts: {
|
||||||
stopScript: hookScriptPath,
|
stopScript: hookScriptPath,
|
||||||
notificationScript: hookScriptPath,
|
notificationScript: hookScriptPath,
|
||||||
@@ -244,7 +298,24 @@ export class CliTaskSession {
|
|||||||
purpose: "execute",
|
purpose: "execute",
|
||||||
taskId: opts.taskId,
|
taskId: opts.taskId,
|
||||||
worktreePath: opts.worktreePath,
|
worktreePath: opts.worktreePath,
|
||||||
posture: opts.config.cliAutonomy ?? null,
|
// Denormalize the EFFECTIVE posture (derived from resolved argv+env) onto
|
||||||
|
// the session record so the posture chip reflects the real launch
|
||||||
|
// posture, not the autonomy field alone. The autonomy intent fields are
|
||||||
|
// preserved (buildLaunch still reads `autoApprove`).
|
||||||
|
posture: {
|
||||||
|
...(opts.config.cliAutonomy ?? {}),
|
||||||
|
// `autonomyMode: "elevated"` is an alternate channel to the field; map
|
||||||
|
// it onto `autoApprove` so the adapter's buildLaunch emits the
|
||||||
|
// privileged flags (it keys off `posture.autoApprove`).
|
||||||
|
...(cliAgentSettings?.autonomyMode === "elevated"
|
||||||
|
? { autoApprove: true }
|
||||||
|
: {}),
|
||||||
|
effectivePosture: {
|
||||||
|
mode: effectivePosture.mode,
|
||||||
|
elevated: effectivePosture.elevated,
|
||||||
|
flags: effectivePosture.flags,
|
||||||
|
},
|
||||||
|
},
|
||||||
settings,
|
settings,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -676,7 +676,35 @@ export {
|
|||||||
DuplicateCliAdapterError,
|
DuplicateCliAdapterError,
|
||||||
type CliAgentAdapter,
|
type CliAgentAdapter,
|
||||||
type CliAdapterCapabilities,
|
type CliAdapterCapabilities,
|
||||||
|
type CliAdapterElevationMarkers,
|
||||||
} from "./cli-agent/adapter.js";
|
} from "./cli-agent/adapter.js";
|
||||||
|
// CLI Agent Executor — autonomy posture resolution + approval gate (U15).
|
||||||
|
export {
|
||||||
|
resolveEffectivePosture,
|
||||||
|
assertAutonomyApproved,
|
||||||
|
CliAutonomyNotApprovedError,
|
||||||
|
GENERIC_ELEVATION_ENV_PATTERNS,
|
||||||
|
tierForCapabilities,
|
||||||
|
type EffectivePosture,
|
||||||
|
type CliElevationFlag,
|
||||||
|
type CliAgentResolveSettings,
|
||||||
|
type CliAgentNodeConfig,
|
||||||
|
type AutonomyApprovalLookup,
|
||||||
|
type ResolveEffectivePostureArgs,
|
||||||
|
type AssertAutonomyApprovedArgs,
|
||||||
|
type CliAdapterTier,
|
||||||
|
} from "./cli-agent/autonomy.js";
|
||||||
|
// CLI Agent Executor — bundled adapters + UI descriptors (U15).
|
||||||
|
export {
|
||||||
|
BUNDLED_CLI_ADAPTERS,
|
||||||
|
listCliAdapterDescriptors,
|
||||||
|
claudeCodeAdapter,
|
||||||
|
codexAdapter,
|
||||||
|
droidAdapter,
|
||||||
|
piAdapter,
|
||||||
|
genericCliAdapter,
|
||||||
|
type CliAdapterDescriptor,
|
||||||
|
} from "./cli-agent/adapters/index.js";
|
||||||
// CLI Agent Executor — task ↔ session orchestration (U7).
|
// CLI Agent Executor — task ↔ session orchestration (U7).
|
||||||
export {
|
export {
|
||||||
CliTaskSession,
|
CliTaskSession,
|
||||||
|
|||||||
@@ -5007,7 +5007,8 @@
|
|||||||
"tooltip": {
|
"tooltip": {
|
||||||
"global": "Shared across all projects",
|
"global": "Shared across all projects",
|
||||||
"project": "Specific to this project"
|
"project": "Specific to this project"
|
||||||
}
|
},
|
||||||
|
"cliAgents": "CLI Agents"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"sending": "Sending…",
|
"sending": "Sending…",
|
||||||
@@ -5050,6 +5051,34 @@
|
|||||||
"openApprovals": "Open Approvals",
|
"openApprovals": "Open Approvals",
|
||||||
"selectWorktreesDir": "Select worktrees directory",
|
"selectWorktreesDir": "Select worktrees directory",
|
||||||
"tryAgain": "Try again"
|
"tryAgain": "Try again"
|
||||||
|
},
|
||||||
|
"cliAgents": {
|
||||||
|
"heading": "CLI Agents",
|
||||||
|
"description": "Per-adapter launch configuration for CLI coding agents driven in engine-owned terminals.",
|
||||||
|
"adapterLabel": "Adapter",
|
||||||
|
"tier": {
|
||||||
|
"native": "native",
|
||||||
|
"hybrid": "hybrid",
|
||||||
|
"generic": "generic"
|
||||||
|
},
|
||||||
|
"commandLabel": "Command override",
|
||||||
|
"commandHelp": "Path or name of the binary to launch. A non-default value is treated as privileged and requires autonomy approval.",
|
||||||
|
"extraArgsLabel": "Extra arguments",
|
||||||
|
"extraArgsHelp": "Appended after the adapter's computed arguments (space-separated). Bypass flags here are detected and gated.",
|
||||||
|
"envLabel": "Environment variable additions",
|
||||||
|
"envHelp": "Comma-separated variable NAMES forwarded from the parent process. Service credentials (FUSION_*) are always excluded.",
|
||||||
|
"autonomyLabel": "Autonomy mode",
|
||||||
|
"autonomyHelp": "Elevated autonomy requires a per-project approval before the agent can launch.",
|
||||||
|
"approvedNote": "Elevated autonomy is approved for this project.",
|
||||||
|
"autonomy": {
|
||||||
|
"default": "Default (request approvals)",
|
||||||
|
"elevated": "Elevated (bypass approvals)"
|
||||||
|
},
|
||||||
|
"elevatedConfirmTitle": "Approve elevated autonomy?",
|
||||||
|
"elevatedConfirmBody": "Elevated autonomy lets this CLI agent bypass per-step approvals (e.g. --dangerously-skip-permissions). It can modify files and run commands without pausing. Approve only if you trust this adapter for this project.",
|
||||||
|
"elevatedConfirmAction": "Approve elevated autonomy",
|
||||||
|
"saveFailed": "Failed to save CLI agent settings",
|
||||||
|
"approveFailed": "Failed to record autonomy approval"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"setup": {
|
"setup": {
|
||||||
@@ -6832,5 +6861,26 @@
|
|||||||
"moreFields": "Additional fields",
|
"moreFields": "Additional fields",
|
||||||
"orphaned": "Orphaned fields",
|
"orphaned": "Orphaned fields",
|
||||||
"saveFailed": "Failed to save field"
|
"saveFailed": "Failed to save field"
|
||||||
|
},
|
||||||
|
"workflowEditor": {
|
||||||
|
"cliAgent": {
|
||||||
|
"executorOption": "CLI agent",
|
||||||
|
"adapterLabel": "CLI adapter",
|
||||||
|
"adapterPlaceholder": "— select adapter —",
|
||||||
|
"adapterNote": "Drives a CLI coding agent in an engine-owned terminal for this step.",
|
||||||
|
"tier": {
|
||||||
|
"native": "native",
|
||||||
|
"hybrid": "hybrid",
|
||||||
|
"generic": "generic"
|
||||||
|
},
|
||||||
|
"autonomyLabel": "Elevated autonomy (bypass approvals)",
|
||||||
|
"autonomyNote": "Elevated autonomy requires a per-project approval before the agent can launch. Until approved, launches with elevated posture fail.",
|
||||||
|
"notifyLabel": "Waiting-on-input notification",
|
||||||
|
"notify": {
|
||||||
|
"banner": "In-app banner",
|
||||||
|
"bannerNotify": "Banner + push notification"
|
||||||
|
},
|
||||||
|
"notifyNote": "How you are alerted when the agent pauses waiting for input on this step."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user