From b28857271319bdc6e2881d3465748ad526f77ccc Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Mon, 5 Oct 2026 07:40:53 -0300 Subject: [PATCH 1/2] feat(agent-config): shared configuration UI pieces Layer 11 of 21 in the stacked split of compliance-framework/ui#318. Co-Authored-By: Claude Opus 5.5 --- src/components/agents/config/CodeLabel.vue | 18 ++ src/components/agents/config/ConfigPill.vue | 32 +++ .../agents/config/ConfigYamlViewer.vue | 96 ++++++++ .../agents/config/InstanceModeNotice.vue | 214 ++++++++++++++++++ .../agents/config/InstanceStatusChip.vue | 17 ++ .../agents/config/LockedKeysPanel.vue | 86 +++++++ .../agents/config/ProvenanceBadge.vue | 33 +++ .../config/__tests__/ConfigYamlViewer.spec.ts | 74 ++++++ .../__tests__/InstanceModeNotice.spec.ts | 58 +++++ .../config/__tests__/LockedKeysPanel.spec.ts | 27 +++ .../agents/config/__tests__/constants.spec.ts | 105 +++++++++ src/components/agents/config/constants.ts | 116 ++++++++++ src/config/tooltips.ts | 8 + 13 files changed, 884 insertions(+) create mode 100644 src/components/agents/config/CodeLabel.vue create mode 100644 src/components/agents/config/ConfigPill.vue create mode 100644 src/components/agents/config/ConfigYamlViewer.vue create mode 100644 src/components/agents/config/InstanceModeNotice.vue create mode 100644 src/components/agents/config/InstanceStatusChip.vue create mode 100644 src/components/agents/config/LockedKeysPanel.vue create mode 100644 src/components/agents/config/ProvenanceBadge.vue create mode 100644 src/components/agents/config/__tests__/ConfigYamlViewer.spec.ts create mode 100644 src/components/agents/config/__tests__/InstanceModeNotice.spec.ts create mode 100644 src/components/agents/config/__tests__/LockedKeysPanel.spec.ts create mode 100644 src/components/agents/config/__tests__/constants.spec.ts create mode 100644 src/components/agents/config/constants.ts diff --git a/src/components/agents/config/CodeLabel.vue b/src/components/agents/config/CodeLabel.vue new file mode 100644 index 00000000..54b77be1 --- /dev/null +++ b/src/components/agents/config/CodeLabel.vue @@ -0,0 +1,18 @@ + + + diff --git a/src/components/agents/config/ConfigPill.vue b/src/components/agents/config/ConfigPill.vue new file mode 100644 index 00000000..5fbb40f9 --- /dev/null +++ b/src/components/agents/config/ConfigPill.vue @@ -0,0 +1,32 @@ + + + diff --git a/src/components/agents/config/ConfigYamlViewer.vue b/src/components/agents/config/ConfigYamlViewer.vue new file mode 100644 index 00000000..c0558d5a --- /dev/null +++ b/src/components/agents/config/ConfigYamlViewer.vue @@ -0,0 +1,96 @@ + + + diff --git a/src/components/agents/config/InstanceModeNotice.vue b/src/components/agents/config/InstanceModeNotice.vue new file mode 100644 index 00000000..4513eaba --- /dev/null +++ b/src/components/agents/config/InstanceModeNotice.vue @@ -0,0 +1,214 @@ + + + diff --git a/src/components/agents/config/InstanceStatusChip.vue b/src/components/agents/config/InstanceStatusChip.vue new file mode 100644 index 00000000..a50550a5 --- /dev/null +++ b/src/components/agents/config/InstanceStatusChip.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/agents/config/LockedKeysPanel.vue b/src/components/agents/config/LockedKeysPanel.vue new file mode 100644 index 00000000..649f7a2a --- /dev/null +++ b/src/components/agents/config/LockedKeysPanel.vue @@ -0,0 +1,86 @@ + + + diff --git a/src/components/agents/config/ProvenanceBadge.vue b/src/components/agents/config/ProvenanceBadge.vue new file mode 100644 index 00000000..0db43b4e --- /dev/null +++ b/src/components/agents/config/ProvenanceBadge.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/components/agents/config/__tests__/ConfigYamlViewer.spec.ts b/src/components/agents/config/__tests__/ConfigYamlViewer.spec.ts new file mode 100644 index 00000000..a7ae9627 --- /dev/null +++ b/src/components/agents/config/__tests__/ConfigYamlViewer.spec.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { flushPromises, mount } from '@vue/test-utils'; +import ConfigYamlViewer from '../ConfigYamlViewer.vue'; +import { globalWith, piniaWith, READER } from './helpers'; + +function mountViewer(doc: object | null) { + return mount(ConfigYamlViewer, { + props: { + doc, + filename: 'agent-overlay-r7.yaml', + emptyText: 'Nothing here.', + legend: 'Locked.', + }, + global: globalWith(piniaWith(READER)), + }); +} + +describe('ConfigYamlViewer', () => { + afterEach(() => vi.restoreAllMocks()); + + it('renders YAML with the legend', () => { + const wrapper = mountViewer({ + verbosity: 1, + plugins: { a: { policy_data: { max_auth_tries: 3 } } }, + }); + const text = wrapper.find('[data-test="yaml-text"]').text(); + expect(text).toContain('verbosity: 1'); + expect(text).toContain('max_auth_tries: 3'); + expect(wrapper.text()).toContain('Locked.'); + }); + + it('copies to the clipboard', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(window.navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + const wrapper = mountViewer({ verbosity: 1 }); + await wrapper.find('[data-test="yaml-copy"]').trigger('click'); + await flushPromises(); + expect(writeText).toHaveBeenCalledWith('verbosity: 1\n'); + }); + + it('downloads a YAML blob with the given filename', async () => { + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:x'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); + const click = vi + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => undefined); + let downloadName = ''; + click.mockImplementation(function (this: HTMLAnchorElement) { + downloadName = this.download; + }); + const wrapper = mountViewer({ verbosity: 1 }); + await wrapper.find('[data-test="yaml-download"]').trigger('click'); + expect(created).toHaveBeenCalled(); + const blob = created.mock.calls[0][0] as Blob; + expect(blob.type).toBe('application/yaml'); + expect(downloadName).toBe('agent-overlay-r7.yaml'); + }); + + it('shows the empty text and disables actions without a document', () => { + const wrapper = mountViewer(null); + expect(wrapper.find('[data-test="yaml-empty"]').text()).toBe( + 'Nothing here.', + ); + expect( + wrapper.find('[data-test="yaml-copy"]').attributes('disabled'), + ).toBeDefined(); + expect( + wrapper.find('[data-test="yaml-download"]').attributes('disabled'), + ).toBeDefined(); + }); +}); diff --git a/src/components/agents/config/__tests__/InstanceModeNotice.spec.ts b/src/components/agents/config/__tests__/InstanceModeNotice.spec.ts new file mode 100644 index 00000000..7e14fbf7 --- /dev/null +++ b/src/components/agents/config/__tests__/InstanceModeNotice.spec.ts @@ -0,0 +1,58 @@ +// The selected instance's notice: rejection details and the tolerated problems in its file. +import { describe, expect, it } from 'vitest'; +import { mount } from '@vue/test-utils'; +import type { AgentInstanceSummary } from '@/types/agent-config'; +import { instancesMixed } from '@/composables/agent-config/__tests__/fixtures'; +import { deriveInstanceState } from '@/utils/agent-config/instance-status'; +import InstanceModeNotice from '../InstanceModeNotice.vue'; +import { globalWith, piniaWith, READER } from './helpers'; + +function mountNotice(over: Partial) { + const instance = { ...instancesMixed.items[0], ...over }; + return mount(InstanceModeNotice, { + props: { instance, state: deriveInstanceState(instance, 7) }, + global: globalWith(piniaWith(READER)), + }); +} + +describe('InstanceModeNotice', () => { + it('explains an invalid-config rejection with its reason and error', () => { + const w = mountNotice({ + status: 'rejected', + reason: 'invalid-config', + error: 'plugins.local-ssh.schedule: invalid cron', + syncStatus: 'out-of-sync', + warnings: [], + }); + const details = w.find('[data-test="rejection-details"]'); + expect(details.exists()).toBe(true); + expect(details.text()).toContain('last-known-good configuration'); + expect(details.text()).toContain('invalid cron'); + }); + + it("lists the tolerated problems in the agent's file", () => { + const w = mountNotice({}); + const block = w.find('[data-test="file-warnings"]'); + expect(block.exists()).toBe(true); + expect(block.text()).toContain('/plugins/nightly-audit/schedule'); + expect(block.text()).toContain('Invalid value'); + }); + + it('shows no rejection details for an applied instance', () => { + const w = mountNotice({ warnings: [] }); + expect(w.find('[data-test="rejection-details"]').exists()).toBe(false); + expect(w.find('[data-test="file-warnings"]').exists()).toBe(false); + }); + + it('explains report-only as the default mode and how to opt in', () => { + const w = mountNotice({ + mode: 'report', + status: 'not-applicable', + syncStatus: 'not-applicable', + warnings: [], + }); + const text = w.find('[data-test="mode-notice"]').text(); + expect(text).toContain('default when remote_config.mode is not set'); + expect(text).toContain('apply_safe'); + }); +}); diff --git a/src/components/agents/config/__tests__/LockedKeysPanel.spec.ts b/src/components/agents/config/__tests__/LockedKeysPanel.spec.ts new file mode 100644 index 00000000..2f2e4cdd --- /dev/null +++ b/src/components/agents/config/__tests__/LockedKeysPanel.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { mount } from '@vue/test-utils'; +import LockedKeysPanel from '../LockedKeysPanel.vue'; +import { globalWith, piniaWith, READER } from './helpers'; + +const modeRow = (doc: object) => + mount(LockedKeysPanel, { + props: { doc }, + global: globalWith(piniaWith(READER)), + }) + .find('[data-test="locked-remote_config.mode"] dd') + .text(); + +describe('LockedKeysPanel', () => { + it('shows an unset remote_config.mode as the API default (R29: report with credentials, off without)', () => { + expect( + modeRow({ api: { auth: { client_id: 'c' } }, remote_config: {} }), + ).toBe('report'); + expect(modeRow({ remote_config: {} })).toBe('off'); + expect( + modeRow({ + api: { auth: { client_id: 'c' } }, + remote_config: { mode: 'apply_all' }, + }), + ).toBe('apply_all'); + }); +}); diff --git a/src/components/agents/config/__tests__/constants.spec.ts b/src/components/agents/config/__tests__/constants.spec.ts new file mode 100644 index 00000000..6218b396 --- /dev/null +++ b/src/components/agents/config/__tests__/constants.spec.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import { + APPLY_REASON_LABELS, + CHANGE_REASON_LABELS, + FIELD_ERROR_CODE_LABELS, + LOCKED_LEGEND, + MODE_TEXT, + NOT_REPORTED_TEXT, + WILL_APPLY_REASON_LABELS, + labelFor, +} from '../constants'; +import { + permissionTooltip, + RESOURCES, + ACTIONS, + MANIFEST_ROLES, +} from '@/constants/permissions'; + +// Vocabulary from compliance-framework/api pkg/agentconfig (classify.go, wire.go, errors.go). +const CHANGE_REASONS = [ + 'locked-key', + 'logging', + 'data-only', + 'reduces-scope', + 'already-used', + 'trusted-source', + 'untrusted-source', + 'local-source-not-allowed', + 'new-local-source', + 'overridable-config-flag', + 'config-not-overridable', + 'new-env-reference', + 'forbidden-env-reference', +]; +const APPLY_REASONS = [ + 'unsafe-changes', + 'forbidden-changes', + 'invalid-config', + 'invalid-type', + 'unknown-field', + 'download-failed', + 'env-missing', + 'unsupported-by-agent', + 'cache-corrupt', + 'internal', +]; +const FIELD_CODES = [ + 'unknown-field', + 'invalid-type', + 'invalid-value', + 'locked-key', + 'size', + 'pattern', + 'cron', + 'duration', + 'source', + 'env-location', + 'forbidden-env', + 'env-missing', + 'masked-value', + 'required', + 'parse', +]; +const WILL_APPLY = [ + 'mode-off', + 'mode-report', + 'unsafe-changes', + 'forbidden-changes', + 'invalid-config', +]; + +describe('agent config labels', () => { + it.each([ + ['change reasons', CHANGE_REASONS, CHANGE_REASON_LABELS], + ['apply reasons', APPLY_REASONS, APPLY_REASON_LABELS], + ['field error codes', FIELD_CODES, FIELD_ERROR_CODE_LABELS], + ['will-apply reasons', WILL_APPLY, WILL_APPLY_REASON_LABELS], + ])('labels exactly the API %s', (_, codes, labels) => { + for (const code of codes) expect(labelFor(labels, code)).toBeTruthy(); + expect(Object.keys(labels).sort()).toEqual([...codes].sort()); + }); + + it('unknown codes have no label (rendered verbatim)', () => { + expect(labelFor(APPLY_REASON_LABELS, 'brand-new-code')).toBeNull(); + expect(labelFor(APPLY_REASON_LABELS, null)).toBeNull(); + }); + + it('plain-text notices carry no Markdown (they render with {{ }})', () => { + for (const t of [ + ...Object.values(MODE_TEXT), + NOT_REPORTED_TEXT, + LOCKED_LEGEND, + ]) { + expect(t).not.toContain('`'); + } + }); + + it('agent permission constants', () => { + expect(permissionTooltip(RESOURCES.AGENT, ACTIONS.CONFIGURE)).toBe( + "You don't have permission to configure agents.", + ); + expect(MANIFEST_ROLES.map((r) => r.name)).not.toContain('policy-author'); + expect(Object.values(ACTIONS)).not.toContain('configure-policy'); + }); +}); diff --git a/src/components/agents/config/constants.ts b/src/components/agents/config/constants.ts new file mode 100644 index 00000000..efa9ab83 --- /dev/null +++ b/src/components/agents/config/constants.ts @@ -0,0 +1,116 @@ +// Display vocabulary for agent remote configuration. Codes come from the API +// (pkg/agentconfig: classify.go, wire.go, errors.go). An unknown code renders verbatim in +// monospace, so a new server code never breaks the UI. + +export const VERBOSITY_OPTIONS = [ + { value: 0, label: 'Info' }, + { value: 1, label: 'Debug' }, + { value: 2, label: 'Trace' }, +] as const; + +export function verbosityLabel(v: unknown): string { + const opt = VERBOSITY_OPTIONS.find((o) => o.value === v); + if (opt) return opt.label; + return v === undefined || v === null + ? 'Info (default)' + : `Custom (${String(v)})`; +} + +// ---- Mode texts (design §6.1) ---- + +export const MODE_TEXT = { + report: + "This agent reports its configuration but does not apply remote changes. Report-only is the default when remote_config.mode is not set; set it to apply_safe or apply_all in the agent's file to opt in.", + apply_safe: + "Accepts policy, schedule and flag changes. New sources need apply_all or a trusted_sources entry. Plugin config keys need a matching overridable_config_flags entry in the agent's file.", + apply_all: 'Applies all changes except locked keys.', +} as const; + +export const NOT_REPORTED_TEXT = + 'No configuration reported yet. The agent may be offline, running a version without remote configuration support, or have remote_config.mode set to off.'; + +export const LOCKED_LEGEND = + 'The api, daemon and remote_config sections are set locally on the agent host and cannot be changed from CCF.'; + +/** R57: overlays are readable by every agent:read holder. */ +export const OVERLAY_SECRETS_NOTICE = + 'Overlays are stored as written and are readable by everyone who can view agents. Do not type secrets here: use a ${env:NAME} placeholder in a plugin config value, which the agent resolves on its own host.'; + +// ---- Classify reasons (API A1.6, classify.go) ---- + +export const CHANGE_REASON_LABELS: Record = { + 'locked-key': 'Locked key (set locally only)', + logging: 'Logging setting', + 'data-only': 'Data only', + 'reduces-scope': 'Reduces scope', + 'already-used': 'Source already used by this agent', + 'trusted-source': 'Trusted source', + 'untrusted-source': 'Untrusted source', + 'local-source-not-allowed': 'Local sources are not allowed on this host', + 'new-local-source': 'New local source', + 'overridable-config-flag': 'Overridable config key', + 'config-not-overridable': 'Config key not overridable', + 'new-env-reference': 'Reads a new host environment variable', + 'forbidden-env-reference': 'Forbidden environment variable', +}; + +// ---- Apply reasons (R10, R42, wire.go Reasons) ---- + +export const APPLY_REASON_LABELS: Record = { + 'unsafe-changes': + 'The revision contains changes this host only applies in apply_all', + 'forbidden-changes': 'The revision contains a forbidden change', + 'invalid-config': + 'The config was invalid; the agent kept its last-known-good configuration', + 'download-failed': 'A plugin or policy bundle could not be downloaded', + 'env-missing': + 'A ${env:…} placeholder names a variable that is not set on the host', + 'cache-corrupt': "The agent's local config cache was corrupt", + internal: 'Internal agent error', + 'unknown-field': 'The overlay has a field this agent does not know', + 'invalid-type': 'A value has the wrong type', + 'unsupported-by-agent': + 'The overlay uses a feature this agent version does not support', +}; + +// ---- FieldError codes (R43, errors.go) ---- + +export const FIELD_ERROR_CODE_LABELS: Record = { + 'unknown-field': 'Unknown field', + 'invalid-type': 'Invalid type', + 'invalid-value': 'Invalid value', + 'locked-key': 'Locked key', + size: 'Too large', + pattern: 'Invalid name', + cron: 'Invalid schedule', + duration: 'Invalid duration', + source: 'Invalid source', + 'env-location': 'Placeholder not allowed here', + 'forbidden-env': 'Forbidden variable', + 'env-missing': 'Variable not set', + 'masked-value': 'Masked value', + required: 'Required', + parse: 'Parse error', +}; + +// ---- Preview will-apply reasons ---- + +export const WILL_APPLY_REASON_LABELS: Record = { + 'mode-off': 'Remote configuration is off on this host', + 'mode-report': + 'Report-only mode: this host does not apply remote configuration', + 'unsafe-changes': 'Needs apply_all for some changes', + 'forbidden-changes': 'Contains a forbidden change (rejected in every mode)', + 'invalid-config': 'The resulting config is invalid for this host', +}; + +/** Label for a code, or null when unknown (callers then render the raw code in monospace). */ +export function labelFor( + labels: Record, + code: string | null | undefined, +): string | null { + if (!code) return null; + return Object.prototype.hasOwnProperty.call(labels, code) + ? labels[code] + : null; +} diff --git a/src/config/tooltips.ts b/src/config/tooltips.ts index 9506cbea..5ea98e64 100644 --- a/src/config/tooltips.ts +++ b/src/config/tooltips.ts @@ -59,6 +59,14 @@ export const TOOLTIPS = { "The agent's report exceeded the size limit, so its local file was dropped: the File view and the checks against this host's file are unavailable", 'agents.config.instance.fileWarnings': "Problems in this agent's local file (tolerated; the affected plugins are skipped)", + 'agents.config.policyData.masked': + 'Masked in the report: the host keeps its value unless you set a new one', + 'agents.config.policyData.env': + '${env:…} is resolved only in plugin config values: here it is a literal string, and the API rejects new references in policy_data', + 'agents.config.policyData.maskedList': + 'Holds masked values: a list is saved whole, which would copy the masks. Edit it in the raw JSON view and retype the masked values, or remove the whole list.', + /** Followed by the reported library version. */ + 'agents.config.plugin.libVersion': 'Built on agent library', // Add more tooltips here as needed // 'feature.name': 'Tooltip text here', From dc03cc2905c8a93e64537fc220e03040bd1beeba Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:15:03 -0300 Subject: [PATCH 2/2] test(agent-config): the applied-instance notice case asserts the notice renders "shows no rejection details for an applied instance" only asserted absences, so it passed with no notice at all (CORE-TEST-002). It now first expects the notice and its mode badge. Co-Authored-By: Claude Opus 5.5 --- .../agents/config/__tests__/InstanceModeNotice.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/agents/config/__tests__/InstanceModeNotice.spec.ts b/src/components/agents/config/__tests__/InstanceModeNotice.spec.ts index 7e14fbf7..f8dca73b 100644 --- a/src/components/agents/config/__tests__/InstanceModeNotice.spec.ts +++ b/src/components/agents/config/__tests__/InstanceModeNotice.spec.ts @@ -40,6 +40,9 @@ describe('InstanceModeNotice', () => { it('shows no rejection details for an applied instance', () => { const w = mountNotice({ warnings: [] }); + // The notice itself renders: the absences below are about its content. + expect(w.find('[data-test="mode-notice"]').exists()).toBe(true); + expect(w.find('[data-test="mode-badge"]').exists()).toBe(true); expect(w.find('[data-test="rejection-details"]').exists()).toBe(false); expect(w.find('[data-test="file-warnings"]').exists()).toBe(false); });