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:
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 type { GlobalSettings } from "./types.js";
|
||||
import { DEFAULT_GLOBAL_SETTINGS } from "./types.js";
|
||||
import { sanitizeCliAgentsSettings } from "./settings-schema.js";
|
||||
|
||||
function getHomeDir(): string {
|
||||
return process.env.HOME || process.env.USERPROFILE || homedir();
|
||||
@@ -193,6 +194,11 @@ export class GlobalSettingsStore {
|
||||
// null → delete this key from the merged object
|
||||
// This effectively makes it fall through to the default
|
||||
delete merged[key];
|
||||
} else if (key === "cliAgents") {
|
||||
// Validation at the write boundary (U15, Global Settings convention):
|
||||
// unknown adapter ids and invalid fields are dropped before persist so
|
||||
// a malformed `cliAgents` payload can never reach launch resolution.
|
||||
merged[key] = sanitizeCliAgentsSettings(value);
|
||||
} else {
|
||||
// normal value → set it
|
||||
merged[key] = value;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
|
||||
export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext } from "./types.js";
|
||||
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES } from "./types.js";
|
||||
export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings } from "./types.js";
|
||||
export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js";
|
||||
export {
|
||||
resolveEntryPointBranchAssignment,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GlobalSettings, ProjectSettings, Settings } from "./types.js";
|
||||
import type { CliAgentSettings, GlobalSettings, ProjectSettings, Settings } from "./types.js";
|
||||
|
||||
export interface MergeRequestContractShadowSettingsSource {
|
||||
mergeRequestContractShadowEnabled?: boolean;
|
||||
@@ -180,6 +180,7 @@ export const DEFAULT_GLOBAL_SETTINGS = {
|
||||
},
|
||||
owningNodeHandoffPolicy: "reassign-to-local",
|
||||
experimentalFeatures: {},
|
||||
cliAgents: {},
|
||||
} satisfies CompleteSettings<GlobalSettings>;
|
||||
|
||||
/** Default values for project-level settings. */
|
||||
@@ -188,6 +189,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
|
||||
globalPauseReason: undefined,
|
||||
defaultWorkflowId: undefined,
|
||||
approvedWorkflowCliCommands: undefined,
|
||||
approvedCliAutonomyAdapters: undefined,
|
||||
enginePaused: false,
|
||||
maxConcurrent: 2,
|
||||
maxTriageConcurrent: 2,
|
||||
@@ -521,3 +523,81 @@ export function resolvePersistAgentThinkingLog(
|
||||
if (typeof settings?.persistAgentThinkingLog === "boolean") return settings.persistAgentThinkingLog;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── CLI-agent settings sanitization (U15) ───────────────────────────────────
|
||||
|
||||
/** Adapter ids accepted in `cliAgents`. Unknown ids are dropped at the write
|
||||
* boundary so a settings file cannot carry config for non-existent adapters. */
|
||||
export const CLI_AGENT_ADAPTER_IDS = Object.freeze([
|
||||
"claude-code",
|
||||
"codex",
|
||||
"droid",
|
||||
"pi",
|
||||
"generic",
|
||||
] as const);
|
||||
|
||||
/** Autonomy modes accepted in a `CliAgentSettings` entry. */
|
||||
export const CLI_AGENT_AUTONOMY_MODES = Object.freeze(["default", "elevated"] as const);
|
||||
|
||||
function sanitizeStringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const cleaned = value
|
||||
.filter((v): v is string => typeof v === "string")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0);
|
||||
return cleaned.length > 0 ? cleaned : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a single adapter's launch settings (U15). Drops unknown fields and
|
||||
* invalid values; returns `undefined` when nothing survives (so the caller can
|
||||
* omit an empty entry). Pure — no I/O.
|
||||
*
|
||||
* Validation rules:
|
||||
* - `commandOverride`: non-empty trimmed string, else dropped.
|
||||
* - `extraArgs` / `envAdditions`: arrays of non-empty trimmed strings, else dropped.
|
||||
* - `autonomyMode`: one of CLI_AGENT_AUTONOMY_MODES, else dropped (falls back to
|
||||
* the adapter baseline at resolution time).
|
||||
*/
|
||||
export function sanitizeCliAgentSettings(value: unknown): CliAgentSettings | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||
const input = value as Record<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>);
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
/**
|
||||
* 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). */
|
||||
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";
|
||||
@@ -3031,6 +3064,12 @@ export interface ProjectSettings {
|
||||
* (trust-on-first-use). A node's command must appear here before it runs;
|
||||
* named scripts (settings.scripts) never require approval. */
|
||||
approvedWorkflowCliCommands?: string[];
|
||||
/** CLI-agent adapter ids the project owner has approved for ELEVATED autonomy
|
||||
* (CLI Agent Executor, U15). An adapter must appear here before a launch whose
|
||||
* resolved posture is elevated (bypass-permissions-style) is permitted; an
|
||||
* unapproved elevation fails the launch with a typed error. Approving
|
||||
* principal in v1: the daemon-token holder (the single workspace owner). */
|
||||
approvedCliAutonomyAdapters?: string[];
|
||||
/** Engine pause (soft pause): when true, the scheduler and triage
|
||||
* processor stop dispatching **new** work (scheduling, triage
|
||||
* specification, and auto-merge), but currently running agent sessions
|
||||
@@ -3891,6 +3930,10 @@ export {
|
||||
isProjectSettingsKey,
|
||||
isMergeRequestContractShadowEnabled,
|
||||
resolvePersistAgentThinkingLog,
|
||||
sanitizeCliAgentSettings,
|
||||
sanitizeCliAgentsSettings,
|
||||
CLI_AGENT_ADAPTER_IDS,
|
||||
CLI_AGENT_AUTONOMY_MODES,
|
||||
} from "./settings-schema.js";
|
||||
|
||||
export interface BoardConfig {
|
||||
|
||||
@@ -263,6 +263,7 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
{ id: "notifications", label: "Notifications", labelKey: "settings.nav.notifications", scope: "global" },
|
||||
{ id: "node-sync", label: "Node Sync", labelKey: "settings.nav.nodeSync", scope: "global" },
|
||||
{ id: "global-models", label: "Models", labelKey: "settings.nav.globalModels", scope: "global" },
|
||||
{ id: "cli-agents", label: "CLI Agents", labelKey: "settings.nav.cliAgents", scope: "global" },
|
||||
{ id: "research-global", label: "Research Defaults", labelKey: "settings.nav.researchGlobal", scope: "global" },
|
||||
{ id: "experimental", label: "Experimental Features", labelKey: "settings.nav.experimental", scope: "global" },
|
||||
{ id: "remote", label: "Remote Access", labelKey: "settings.nav.remote", scope: "global" },
|
||||
@@ -422,6 +423,233 @@ interface SettingsModalProps {
|
||||
onOpenApprovals?: (approvalId?: string) => void;
|
||||
}
|
||||
|
||||
/** Adapter descriptor served by GET /api/cli-agents (U15). */
|
||||
interface CliAdapterDescriptorView {
|
||||
id: string;
|
||||
name: string;
|
||||
tier: "native" | "hybrid" | "generic";
|
||||
defaultCommand: string | null;
|
||||
}
|
||||
|
||||
interface CliAgentSettingsEntry {
|
||||
commandOverride?: string;
|
||||
extraArgs?: string[];
|
||||
autonomyMode?: "default" | "elevated";
|
||||
envAdditions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-adapter CLI-agent launch settings section (U15). Reads the adapter catalog
|
||||
* + persisted settings + per-project autonomy approval state, and lets the
|
||||
* operator edit command override / extra args / env additions / autonomy mode.
|
||||
* Switching an adapter to elevated autonomy goes through an explicit
|
||||
* confirmation flow before the per-project approval is granted.
|
||||
*/
|
||||
function CliAgentsSettingsSection({
|
||||
projectId,
|
||||
addToast,
|
||||
}: {
|
||||
projectId?: string;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("app");
|
||||
const { confirm } = useConfirm();
|
||||
const [adapters, setAdapters] = useState<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({
|
||||
onClose,
|
||||
addToast,
|
||||
@@ -2328,6 +2556,13 @@ export function SettingsModal({
|
||||
|
||||
const renderSectionFields = () => {
|
||||
switch (activeSection) {
|
||||
case "cli-agents":
|
||||
return (
|
||||
<>
|
||||
{renderScopeBanner()}
|
||||
<CliAgentsSettingsSection projectId={projectId} addToast={addToast} />
|
||||
</>
|
||||
);
|
||||
case "general":
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -60,7 +60,23 @@ import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel";
|
||||
import type { WorkflowFieldDefinition } from "../api";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
|
||||
type ExecutorKind = "model" | "agent" | "skill" | "cli";
|
||||
type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent";
|
||||
|
||||
/** Adapter descriptor served by GET /api/cli-agents (U15). */
|
||||
interface CliAdapterDescriptorView {
|
||||
id: string;
|
||||
name: string;
|
||||
tier: "native" | "hybrid" | "generic";
|
||||
}
|
||||
|
||||
/** Static fallback so the picker renders before/without the API fetch. */
|
||||
const CLI_AGENT_ADAPTER_FALLBACK: CliAdapterDescriptorView[] = [
|
||||
{ id: "claude-code", name: "Claude Code", tier: "native" },
|
||||
{ id: "codex", name: "Codex", tier: "hybrid" },
|
||||
{ id: "droid", name: "Droid", tier: "hybrid" },
|
||||
{ id: "pi", name: "Pi", tier: "hybrid" },
|
||||
{ id: "generic", name: "Generic CLI", tier: "generic" },
|
||||
];
|
||||
|
||||
// Mirror of @fusion/core's isBuiltinWorkflowId / BUILTIN_WORKFLOW_ID_PREFIX.
|
||||
// Inlined because the dashboard app build aliases "@fusion/core" to its
|
||||
@@ -551,9 +567,29 @@ function InnerEditor({
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
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";
|
||||
|
||||
useEffect(() => {
|
||||
if (currentExecutor !== "cli-agent") return;
|
||||
let cancelled = false;
|
||||
fetch("/api/cli-agents")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => {
|
||||
if (cancelled || !data?.adapters) return;
|
||||
setCliAdapters(data.adapters as CliAdapterDescriptorView[]);
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the static fallback; the picker stays functional.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentExecutor]);
|
||||
|
||||
useEffect(() => {
|
||||
// step-review offers an optional review model picker (KTD-4).
|
||||
if (selectedNode?.data.kind === "step-review" && models.length === 0) {
|
||||
@@ -774,6 +810,7 @@ function InnerEditor({
|
||||
<option value="agent">Agent</option>
|
||||
<option value="skill">Skill</option>
|
||||
<option value="cli">CLI / script</option>
|
||||
<option value="cli-agent">{t("workflowEditor.cliAgent.executorOption")}</option>
|
||||
</select>
|
||||
</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">
|
||||
<input
|
||||
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 { registerDiagnosticsRoutes } from "./routes/register-diagnostics-routes.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 { registerApprovalRoutes } from "./routes/register-approval-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).
|
||||
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 ────────────────────────────
|
||||
//
|
||||
// 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 ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
/** Stable identifier (e.g. "claude-code", "codex", "generic"). */
|
||||
readonly id: string;
|
||||
@@ -136,6 +163,17 @@ export interface CliAgentAdapter {
|
||||
readonly name: string;
|
||||
/** Capability flags — read honestly by the pipeline and UI. */
|
||||
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. */
|
||||
buildLaunch(ctx: CliAdapterLaunchContext): CliLaunchSpec;
|
||||
|
||||
@@ -539,6 +539,22 @@ export const claudeCodeAdapter: CliAgentAdapter = {
|
||||
id: "claude-code",
|
||||
name: "Claude Code",
|
||||
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 {
|
||||
const settings = readSettings(ctx);
|
||||
|
||||
@@ -579,6 +579,35 @@ export const codexAdapter: CliAgentAdapter = {
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
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 {
|
||||
const settings = readSettings(ctx);
|
||||
|
||||
@@ -471,6 +471,23 @@ export const droidAdapter: CliAgentAdapter = {
|
||||
id: "droid",
|
||||
name: "Droid",
|
||||
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 {
|
||||
const settings = readSettings(ctx);
|
||||
|
||||
@@ -347,6 +347,19 @@ export class GenericCliAdapter implements CliAgentAdapter {
|
||||
readonly id = "generic";
|
||||
readonly name = "Generic CLI";
|
||||
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 {
|
||||
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",
|
||||
name: "Pi",
|
||||
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 {
|
||||
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,
|
||||
cleanupSessionHookDir,
|
||||
} from "./hook-scripts.js";
|
||||
import {
|
||||
assertAutonomyApproved,
|
||||
type AutonomyApprovalLookup,
|
||||
type CliAgentResolveSettings,
|
||||
type EffectivePosture,
|
||||
} from "./autonomy.js";
|
||||
|
||||
// ── Outcome ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -108,6 +114,12 @@ export interface ResolvedCliExecutorConfig {
|
||||
cliNotify?: Record<string, unknown> | null;
|
||||
/** Adapter launch settings (model, command override, extra args, …). */
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
@@ -140,6 +152,13 @@ export interface LaunchCliTaskSessionOptions {
|
||||
* dir; production callers may scope it under the engine's runtime dir.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
@@ -205,6 +224,19 @@ export class CliTaskSession {
|
||||
const log = opts.log ?? (() => {});
|
||||
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.
|
||||
const root = opts.hookDirRoot ?? tmpdir();
|
||||
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 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
|
||||
// settings flow reads `hookScripts` + `settingsPath` off ctx.settings; other
|
||||
// adapters ignore unknown keys.
|
||||
const settings: Record<string, unknown> = {
|
||||
...(opts.config.settings ?? {}),
|
||||
...(cliAgentSettings?.commandOverride
|
||||
? { command: cliAgentSettings.commandOverride }
|
||||
: {}),
|
||||
...(cliAgentSettings?.extraArgs && cliAgentSettings.extraArgs.length > 0
|
||||
? { extraArgs: [...cliAgentSettings.extraArgs] }
|
||||
: {}),
|
||||
envAllowlist: [...new Set([...priorAllowlist, ...operatorEnvAdditions])],
|
||||
hookScripts: {
|
||||
stopScript: hookScriptPath,
|
||||
notificationScript: hookScriptPath,
|
||||
@@ -244,7 +298,24 @@ export class CliTaskSession {
|
||||
purpose: "execute",
|
||||
taskId: opts.taskId,
|
||||
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,
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -676,7 +676,35 @@ export {
|
||||
DuplicateCliAdapterError,
|
||||
type CliAgentAdapter,
|
||||
type CliAdapterCapabilities,
|
||||
type CliAdapterElevationMarkers,
|
||||
} 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).
|
||||
export {
|
||||
CliTaskSession,
|
||||
|
||||
@@ -5007,7 +5007,8 @@
|
||||
"tooltip": {
|
||||
"global": "Shared across all projects",
|
||||
"project": "Specific to this project"
|
||||
}
|
||||
},
|
||||
"cliAgents": "CLI Agents"
|
||||
},
|
||||
"notifications": {
|
||||
"sending": "Sending…",
|
||||
@@ -5050,6 +5051,34 @@
|
||||
"openApprovals": "Open Approvals",
|
||||
"selectWorktreesDir": "Select worktrees directory",
|
||||
"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": {
|
||||
@@ -6832,5 +6861,26 @@
|
||||
"moreFields": "Additional fields",
|
||||
"orphaned": "Orphaned fields",
|
||||
"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