From a53bead995b991fb86f9ef67ff15aafade3d9663 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Mon, 5 Oct 2026 07:40:53 -0300 Subject: [PATCH 1/3] feat(agent-config): agent config API client, configure permission and review helpers Layer 9 of 21 in the stacked split of compliance-framework/ui#318. Co-Authored-By: Claude Opus 5.5 --- src/components/agents/config/editor/review.ts | 84 +++++ src/composables/__tests__/axios.spec.ts | 117 +++++++ .../__tests__/useAgentConfigApi.spec.ts | 258 +++++++++++++++ .../agent-config/useAgentConfigApi.ts | 297 ++++++++++++++++++ src/composables/axios/index.ts | 10 + src/composables/usePermissions.ts | 4 + src/constants/permissions.ts | 6 + 7 files changed, 776 insertions(+) create mode 100644 src/components/agents/config/editor/review.ts create mode 100644 src/composables/agent-config/__tests__/useAgentConfigApi.spec.ts create mode 100644 src/composables/agent-config/useAgentConfigApi.ts diff --git a/src/components/agents/config/editor/review.ts b/src/components/agents/config/editor/review.ts new file mode 100644 index 00000000..793bdbf3 --- /dev/null +++ b/src/components/agents/config/editor/review.ts @@ -0,0 +1,84 @@ +// Pure helpers for the review step (LLD U2.5). + +import type { + ChangeSafety, + ConfigChange, + ConfigErrorBody, + ConfigPreview, + InstancePreview, +} from '@/types/agent-config'; +import { isPrefix } from '@/utils/agent-config/json-pointer'; + +export type SafetyTagKind = ChangeSafety | 'no-effect'; + +const RANK: Record = { safe: 0, unsafe: 1, forbidden: 2 }; + +/** + * The safety of a diff row: the most specific change whose path equals the row pointer or is + * its prefix; failing that (e.g. a whole plugin added), the worst change under the row; no + * change at all means the row matches the instance's file again ("no effect vs file"). + */ +export function safetyForRow( + path: string, + changes: ConfigChange[], +): SafetyTagKind { + let best: ConfigChange | null = null; + for (const c of changes) { + if (isPrefix(c.path, path) && (!best || c.path.length > best.path.length)) + best = c; + } + if (best) { + // Several changes can share the most specific path (e.g. two new policy sources). + const same = changes.filter((c) => c.path === best!.path); + return same.reduce( + (w, c) => (RANK[c.safety] > RANK[w] ? c.safety : w), + 'safe', + ); + } + const under = changes.filter((c) => isPrefix(path, c.path)); + if (!under.length) return 'no-effect'; + return under.reduce( + (w, c) => (RANK[c.safety] > RANK[w] ? c.safety : w), + 'safe', + ); +} + +/** Whether an instance's errors block a save (R48). Older APIs lack `validated` (§C). */ +export function instanceErrorsBlock(inst: InstancePreview): boolean { + if (!inst.errors.length) return false; + if (inst.validated === undefined) return !inst.stale; + return inst.validated; +} + +export function previewBlocks(preview: ConfigPreview | null): boolean { + if (!preview) return false; + return ( + preview.overlayErrors.length > 0 || + preview.instances.some(instanceErrorsBlock) + ); +} + +export function saveErrorsBlock( + body: ConfigErrorBody | null | undefined, +): boolean { + if (!body) return false; + return ( + (body.overlay?.length ?? 0) > 0 || + (body.instances ?? []).some((i) => i.errors?.length) + ); +} + +export function truncate( + value: unknown, + max = 120, +): { text: string; truncated: boolean } { + const text = + value === undefined + ? '' + : typeof value === 'string' + ? value + : JSON.stringify(value); + return text.length > max + ? { text: `${text.slice(0, max)}…`, truncated: true } + : { text, truncated: false }; +} diff --git a/src/composables/__tests__/axios.spec.ts b/src/composables/__tests__/axios.spec.ts index e824cded..b2a82523 100644 --- a/src/composables/__tests__/axios.spec.ts +++ b/src/composables/__tests__/axios.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import type { InternalAxiosRequestConfig } from 'axios'; import { useAuthenticatedInstance } from '@/composables/axios'; vi.mock('@/stores/config.ts', () => ({ @@ -67,3 +68,119 @@ describe('axios response conversion', () => { }); }); }); + +describe('jsonBody', () => { + it('serialises without key transformation and sets the JSON content type', async () => { + const { jsonBody } = await import('@/composables/axios'); + const { AxiosHeaders } = await import('axios'); + const headers = new AxiosHeaders(); + const data = { overlay: { policy_data: { a_b: 1 }, 'kebab-key': 2 } }; + expect(jsonBody(data, headers)).toBe(JSON.stringify(data)); + expect(headers.get('Content-Type')).toBe('application/json'); + }); +}); + +describe('agent config stop paths', () => { + const adapterFor = + (payload: unknown) => async (config: InternalAxiosRequestConfig) => ({ + data: payload, + status: 200, + statusText: 'OK', + headers: {}, + config, + }); + + it('keeps snake_case overlay keys verbatim (config)', async () => { + const { STOP_PATHS } = await import( + '@/composables/agent-config/useAgentConfigApi' + ); + const instance = useAuthenticatedInstance(); + const response = await instance.get('/x', { + camelcaseStopPaths: STOP_PATHS.config, + adapter: adapterFor({ + data: { + 'agent-id': 'a', + overlay: { + plugins: { 'local-ssh': { policy_data: { max_auth_tries: 3 } } }, + }, + }, + }), + }); + expect(response.data.data).toEqual({ + agentId: 'a', + overlay: { + plugins: { 'local-ssh': { policy_data: { max_auth_tries: 3 } } }, + }, + }); + }); + + it('keeps base/effective/remote-config verbatim inside arrays (instances list)', async () => { + const { STOP_PATHS } = await import( + '@/composables/agent-config/useAgentConfigApi' + ); + const instance = useAuthenticatedInstance(); + const response = await instance.get('/x', { + camelcaseStopPaths: STOP_PATHS.instances, + adapter: adapterFor({ + data: [ + { + 'instance-id': 'i', + 'remote-config': { + trusted_sources: ['a'], + overridable_config_flags: [], + }, + base: { agent_evidence: { emit_on_run_completion: true } }, + 'report-stale': false, + }, + ], + meta: { 'desired-revision': 7, counts: { 'in-sync': 1 } }, + }), + }); + expect(response.data).toEqual({ + data: [ + { + instanceId: 'i', + remoteConfig: { + trusted_sources: ['a'], + overridable_config_flags: [], + }, + base: { agent_evidence: { emit_on_run_completion: true } }, + reportStale: false, + }, + ], + meta: { desiredRevision: 7, counts: { inSync: 1 } }, + }); + }); + + it('keeps preview effective and diff values verbatim', async () => { + const { STOP_PATHS } = await import( + '@/composables/agent-config/useAgentConfigApi' + ); + const instance = useAuthenticatedInstance(); + const response = await instance.get('/x', { + camelcaseStopPaths: STOP_PATHS.preview, + adapter: adapterFor({ + data: { + instances: [ + { + 'instance-id': 'i', + effective: { agent_evidence: { emit_on_run_completion: true } }, + 'diff-vs-current': [ + { path: '/p', op: 'replace', from: { a_b: 1 }, to: { c_d: 2 } }, + ], + 'will-apply-reason': 'mode-report', + }, + ], + }, + }), + }); + expect(response.data.data.instances[0]).toEqual({ + instanceId: 'i', + effective: { agent_evidence: { emit_on_run_completion: true } }, + diffVsCurrent: [ + { path: '/p', op: 'replace', from: { a_b: 1 }, to: { c_d: 2 } }, + ], + willApplyReason: 'mode-report', + }); + }); +}); diff --git a/src/composables/agent-config/__tests__/useAgentConfigApi.spec.ts b/src/composables/agent-config/__tests__/useAgentConfigApi.spec.ts new file mode 100644 index 00000000..873a5bee --- /dev/null +++ b/src/composables/agent-config/__tests__/useAgentConfigApi.spec.ts @@ -0,0 +1,258 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AxiosError, AxiosHeaders } from 'axios'; +import { jsonBody } from '@/composables/axios'; +import { + AgentConfigApiError, + STOP_PATHS, + createHttpAgentConfigApi, + toAgentConfigError, +} from '../useAgentConfigApi'; +import { error409, error422, error428 } from './fixtures'; + +vi.mock('@/composables/axios', async () => { + const actual = await vi.importActual( + '@/composables/axios', + ); + return { ...actual, useAuthenticatedInstance: vi.fn() }; +}); + +const get = vi.fn(); +const put = vi.fn(); +const post = vi.fn(); +const api = createHttpAgentConfigApi({ get, put, post } as never); + +function axiosError(status: number, data: unknown): AxiosError { + const err = new AxiosError('Request failed', 'ERR_BAD_RESPONSE'); + err.response = { + status, + data, + statusText: '', + headers: {}, + config: { headers: new AxiosHeaders() }, + }; + return err; +} + +const rev = (n: number) => ({ + agentId: 'a1', + revision: n, + overlay: {}, + overlaySize: 2, +}); + +describe('useAgentConfigApi (HTTP client)', () => { + beforeEach(() => { + get.mockReset(); + put.mockReset(); + post.mockReset(); + }); + + it('getConfig: URL and stop paths', async () => { + get.mockResolvedValue({ status: 200, data: { data: rev(7) } }); + await expect(api.getConfig('a1')).resolves.toEqual(rev(7)); + expect(get).toHaveBeenCalledWith('/api/admin/agents/a1/config', { + camelcaseStopPaths: STOP_PATHS.config, + }); + }); + + it('putConfig: If-Match "7", jsonBody, verbatim body, 201 vs 200', async () => { + const overlay = { + plugins: { 'local-ssh': { policy_data: { max_auth_tries: 3 } } }, + }; + put.mockResolvedValueOnce({ status: 201, data: { data: rev(8) } }); + await expect( + api.putConfig('a1', { overlay, comment: ' why ' }, 7), + ).resolves.toEqual({ + revision: rev(8), + created: true, + }); + const [url, body, config] = put.mock.calls[0]; + expect(url).toBe('/api/admin/agents/a1/config'); + expect(body).toEqual({ overlay, comment: 'why' }); + expect(config.headers).toEqual({ 'If-Match': '"7"' }); + expect(config.transformRequest).toEqual([jsonBody]); + expect(config.camelcaseStopPaths).toBe(STOP_PATHS.config); + + put.mockResolvedValueOnce({ status: 200, data: { data: rev(7) } }); + await expect(api.putConfig('a1', { overlay }, 7)).resolves.toMatchObject({ + created: false, + }); + expect(put.mock.calls[1][1]).toEqual({ overlay }); + }); + + it('preview: POST body, stop paths, signal', async () => { + const signal = new AbortController().signal; + post.mockResolvedValue({ + status: 200, + data: { data: { standalone: true } }, + }); + await api.preview('a1', { verbosity: 1 }, signal); + expect(post).toHaveBeenCalledWith( + '/api/admin/agents/a1/config/preview', + { overlay: { verbosity: 1 } }, + { + transformRequest: [jsonBody], + camelcaseStopPaths: STOP_PATHS.preview, + signal, + }, + ); + }); + + it('listRevisions: pagination envelope (totalPages, R49)', async () => { + get.mockResolvedValue({ + status: 200, + data: { data: [rev(2)], total: 21, page: 2, limit: 20, totalPages: 2 }, + }); + await expect(api.listRevisions('a1', 2, 20)).resolves.toEqual({ + items: [rev(2)], + total: 21, + totalPages: 2, + }); + expect(get).toHaveBeenCalledWith('/api/admin/agents/a1/config/revisions', { + params: { page: 2, limit: 20 }, + }); + }); + + it('getRevision and revert', async () => { + get.mockResolvedValue({ status: 200, data: { data: rev(3) } }); + await api.getRevision('a1', 3); + expect(get).toHaveBeenCalledWith( + '/api/admin/agents/a1/config/revisions/3', + { + camelcaseStopPaths: STOP_PATHS.revisions, + }, + ); + post.mockResolvedValue({ status: 201, data: { data: rev(8) } }); + await expect(api.revert('a1', 3, 7, 'back')).resolves.toMatchObject({ + created: true, + }); + const [url, body, config] = post.mock.calls[0]; + expect(url).toBe('/api/admin/agents/a1/config/revisions/3/revert'); + expect(body).toEqual({ comment: 'back' }); + expect(config.headers).toEqual({ 'If-Match': '"7"' }); + expect(config.transformRequest).toEqual([jsonBody]); + }); + + it('listInstances and getInstance: stop paths', async () => { + get.mockResolvedValueOnce({ + status: 200, + data: { data: [], meta: { desiredRevision: 0 } }, + }); + await expect(api.listInstances('a1')).resolves.toEqual({ + items: [], + meta: { desiredRevision: 0 }, + }); + expect(get).toHaveBeenLastCalledWith('/api/admin/agents/a1/instances', { + camelcaseStopPaths: STOP_PATHS.instances, + }); + get.mockResolvedValueOnce({ + status: 200, + data: { data: { instanceId: 'i1' } }, + }); + await api.getInstance('a1', 'i1'); + expect(get).toHaveBeenLastCalledWith('/api/admin/agents/a1/instances/i1', { + camelcaseStopPaths: STOP_PATHS.instances, + }); + }); + + it('maps 409 to conflict with current-revision read from the raw kebab body', async () => { + put.mockRejectedValue(axiosError(409, error409)); + const err = await api.putConfig('a1', { overlay: {} }, 7).catch((e) => e); + expect(err).toBeInstanceOf(AgentConfigApiError); + expect(err).toMatchObject({ + kind: 'conflict', + status: 409, + currentRevision: 8, + }); + }); + + it('maps 422 to invalid and keeps the raw body', async () => { + put.mockRejectedValue(axiosError(422, error422)); + const err = await api.putConfig('a1', { overlay: {} }, 7).catch((e) => e); + expect(err.kind).toBe('invalid'); + expect(err.body.overlay).toHaveLength(1); + expect(err.body.instances[0]['instance-id']).toBeTruthy(); + expect(err.message).toBe('configuration overlay is invalid'); + }); + + it.each([ + [403, { errors: { body: 'forbidden' } }, 'forbidden'], + [413, { errors: { body: 'too big' } }, 'too-large'], + [500, { errors: { body: 'boom' } }, 'other'], + ])('maps %s to %s', (status, body, kind) => { + expect(toAgentConfigError(axiosError(status, body), 'putConfig').kind).toBe( + kind, + ); + }); + + it('maps a 404 without an error body on getConfig/listInstances to unsupported, but not a missing agent', () => { + expect( + toAgentConfigError(axiosError(404, { message: 'Not Found' }), 'getConfig') + .kind, + ).toBe('unsupported'); + expect( + toAgentConfigError( + axiosError(404, { message: 'Not Found' }), + 'listInstances', + ).kind, + ).toBe('unsupported'); + expect( + toAgentConfigError( + axiosError(404, { errors: { body: 'agent not found' } }), + 'listInstances', + ).kind, + ).toBe('other'); + expect( + toAgentConfigError( + axiosError(404, { errors: { body: 'agent not found' } }), + 'getConfig', + ).kind, + ).toBe('other'); + expect( + toAgentConfigError( + axiosError(404, { message: 'Not Found' }), + 'getInstance', + ).kind, + ).toBe('other'); + }); + + it('logs a 428 as a UI bug', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + expect( + toAgentConfigError(axiosError(428, error428), 'putConfig').kind, + ).toBe('other'); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + + it('maps a response-less failure to network', () => { + expect( + toAgentConfigError( + new AxiosError('Network Error', 'ERR_NETWORK'), + 'getConfig', + ).kind, + ).toBe('network'); + }); +}); + +describe('useAgentConfigApi never sends the mask (R25)', () => { + it('refuses a PUT or preview whose overlay contains "••••" without calling the API', async () => { + const localPut = vi.fn(); + const localPost = vi.fn(); + const client = createHttpAgentConfigApi({ + get: vi.fn(), + put: localPut, + post: localPost, + } as never); + const overlay = { plugins: { a: { config: { api_key: '••••' } } } }; + const err = await client.putConfig('a1', { overlay }, 7).catch((e) => e); + expect(err).toBeInstanceOf(AgentConfigApiError); + expect(err.kind).toBe('invalid'); + expect(err.body.overlay[0].path).toBe('/plugins/a/config/api_key'); + await expect(client.preview('a1', overlay)).rejects.toMatchObject({ + kind: 'invalid', + }); + expect(localPut).not.toHaveBeenCalled(); + expect(localPost).not.toHaveBeenCalled(); + }); +}); diff --git a/src/composables/agent-config/useAgentConfigApi.ts b/src/composables/agent-config/useAgentConfigApi.ts new file mode 100644 index 00000000..069397be --- /dev/null +++ b/src/composables/agent-config/useAgentConfigApi.ts @@ -0,0 +1,297 @@ +// The single HTTP client for agent remote configuration (LLD U0.3). It uses +// useAuthenticatedInstance() directly (like useLineage) rather than useDataApi, because it +// needs per-call stop paths, If-Match headers and status-aware results (200 vs 201). +// +// Must be called in setup(): useAuthenticatedInstance needs the router and the toast. + +import type { AxiosInstance, AxiosResponse } from 'axios'; +import { isAxiosError } from 'axios'; +import { jsonBody, useAuthenticatedInstance } from '@/composables/axios'; +import type { + AgentConfigRevision, + AgentConfigRevisionSummary, + AgentInstanceDetail, + AgentInstanceSummary, + ConfigErrorBody, + ConfigPreview, + InstancesMeta, + SaveConfigRequest, + SaveResult, +} from '@/types/agent-config'; +import { AgentConfigApiError, type AgentConfigApi } from './api-types'; +import { maskedPointers } from '@/utils/agent-config/validation'; + +export * from './api-types'; + +/** + * camelcase-keys matches stop paths against the ORIGINAL (pre-camelCase, kebab) keys, and + * array indices are not part of the path (R15, R46). Any map keyed by user-defined names is + * opaque. + */ +export const STOP_PATHS = { + config: ['data.overlay'], + revisions: ['data.overlay'], + // List (array) and detail (object) both resolve to data.. + instances: ['data.base', 'data.effective', 'data.remote-config'], + preview: [ + 'data.instances.effective', + 'data.instances.diff-vs-current.from', + 'data.instances.diff-vs-current.to', + ], +} as const; + +function errorBodyOf(error: unknown): ConfigErrorBody | undefined { + if (!isAxiosError(error)) return undefined; + const data = error.response?.data as { errors?: ConfigErrorBody } | undefined; + if ( + data && + typeof data === 'object' && + data.errors && + typeof data.errors === 'object' + ) { + return data.errors; + } + return undefined; +} + +/** Normalises any failure into an AgentConfigApiError (LLD U0.3 table). */ +export function toAgentConfigError( + error: unknown, + op: keyof AgentConfigApi, +): AgentConfigApiError { + if (error instanceof AgentConfigApiError) return error; + if (isAxiosError(error) && error.code === 'ERR_CANCELED') { + return new AgentConfigApiError({ + kind: 'other', + message: 'Request cancelled', + }); + } + const status = isAxiosError(error) ? error.response?.status : undefined; + const body = errorBodyOf(error); + const raw = isAxiosError(error) + ? (error.response?.data as { message?: string } | undefined) + : undefined; + const message = + body?.body || + (raw && typeof raw === 'object' && typeof raw.message === 'string' + ? raw.message + : '') || + (error instanceof Error ? error.message : '') || + 'Request failed'; + + if (status === undefined) { + return new AgentConfigApiError({ + kind: 'network', + message: message || 'Network error', + }); + } + switch (status) { + case 409: + return new AgentConfigApiError({ + kind: 'conflict', + status, + body, + message, + currentRevision: + typeof body?.['current-revision'] === 'number' + ? body['current-revision'] + : undefined, + }); + case 422: + return new AgentConfigApiError({ + kind: 'invalid', + status, + body, + message, + }); + case 403: + return new AgentConfigApiError({ + kind: 'forbidden', + status, + body, + message, + }); + case 413: + return new AgentConfigApiError({ + kind: 'too-large', + status, + body, + message: 'The configuration is too large (request limit 1 MiB).', + }); + case 404: + // A missing ROUTE (old API) has no {errors:{body}}; a missing agent or instance does. + // Both initial-load ops map it, so the result doesn't depend on which 404 lands first. + if ((op === 'getConfig' || op === 'listInstances') && !body) { + return new AgentConfigApiError({ + kind: 'unsupported', + status, + message: 'This CCF API version does not support agent configuration.', + }); + } + return new AgentConfigApiError({ kind: 'other', status, body, message }); + case 428: + console.error( + 'agent config: missing If-Match on a write (UI bug)', + error, + ); + return new AgentConfigApiError({ + kind: 'other', + status, + body, + message: `Missing If-Match: ${message}`, + }); + default: + return new AgentConfigApiError({ kind: 'other', status, body, message }); + } +} + +const ifMatch = (rev: number) => ({ 'If-Match': `"${rev}"` }); + +/** + * Defence in depth (R25): a masked report value must never be sent back. The editor already + * blocks it; this refuses locally if a caller ever skips that validation. + */ +function refuseMasked(overlay: unknown): void { + const ptrs = maskedPointers(overlay); + if (!ptrs.length) return; + const body = + 'The overlay contains a masked value ("••••") copied from a report; it was not sent.'; + throw new AgentConfigApiError({ + kind: 'invalid', + message: body, + body: { + body, + overlay: ptrs.map((path) => ({ + path, + code: 'masked-value', + message: 'This looks like a masked value copied from a report', + })), + }, + }); +} + +function saveResult( + res: AxiosResponse<{ data: AgentConfigRevision }>, +): SaveResult { + return { revision: res.data.data, created: res.status === 201 }; +} + +export function createHttpAgentConfigApi( + instance: AxiosInstance, +): AgentConfigApi { + const base = (agentId: string) => + `/api/admin/agents/${encodeURIComponent(agentId)}`; + + async function call( + op: keyof AgentConfigApi, + fn: () => Promise, + ): Promise { + try { + return await fn(); + } catch (e) { + throw toAgentConfigError(e, op); + } + } + + return { + getConfig: (agentId) => + call('getConfig', async () => { + const res = await instance.get<{ data: AgentConfigRevision }>( + `${base(agentId)}/config`, + { + camelcaseStopPaths: STOP_PATHS.config, + }, + ); + return res.data.data; + }), + putConfig: (agentId, body, rev) => + call('putConfig', async () => { + refuseMasked(body.overlay); + const payload: SaveConfigRequest = { overlay: body.overlay }; + if (body.comment && body.comment.trim()) + payload.comment = body.comment.trim(); + const res = await instance.put<{ data: AgentConfigRevision }>( + `${base(agentId)}/config`, + payload, + { + headers: ifMatch(rev), + transformRequest: [jsonBody], + camelcaseStopPaths: STOP_PATHS.config, + }, + ); + return saveResult(res); + }), + preview: (agentId, overlay, signal) => + call('preview', async () => { + refuseMasked(overlay); + const res = await instance.post<{ data: ConfigPreview }>( + `${base(agentId)}/config/preview`, + { overlay }, + { + transformRequest: [jsonBody], + camelcaseStopPaths: STOP_PATHS.preview, + signal, + }, + ); + return res.data.data; + }), + listRevisions: (agentId, page, limit) => + call('listRevisions', async () => { + const res = await instance.get<{ + data: AgentConfigRevisionSummary[]; + total: number; + totalPages: number; + }>(`${base(agentId)}/config/revisions`, { params: { page, limit } }); + return { + items: res.data.data ?? [], + total: res.data.total ?? 0, + totalPages: res.data.totalPages ?? 1, + }; + }), + getRevision: (agentId, rev) => + call('getRevision', async () => { + const res = await instance.get<{ data: AgentConfigRevision }>( + `${base(agentId)}/config/revisions/${rev}`, + { camelcaseStopPaths: STOP_PATHS.revisions }, + ); + return res.data.data; + }), + revert: (agentId, rev, current, comment) => + call('revert', async () => { + const payload = + comment && comment.trim() ? { comment: comment.trim() } : {}; + const res = await instance.post<{ data: AgentConfigRevision }>( + `${base(agentId)}/config/revisions/${rev}/revert`, + payload, + { + headers: ifMatch(current), + transformRequest: [jsonBody], + camelcaseStopPaths: STOP_PATHS.revisions, + }, + ); + return saveResult(res); + }), + listInstances: (agentId) => + call('listInstances', async () => { + const res = await instance.get<{ + data: AgentInstanceSummary[]; + meta: InstancesMeta; + }>(`${base(agentId)}/instances`, { + camelcaseStopPaths: STOP_PATHS.instances, + }); + return { items: res.data.data ?? [], meta: res.data.meta }; + }), + getInstance: (agentId, instanceId) => + call('getInstance', async () => { + const res = await instance.get<{ data: AgentInstanceDetail }>( + `${base(agentId)}/instances/${encodeURIComponent(instanceId)}`, + { camelcaseStopPaths: STOP_PATHS.instances }, + ); + return res.data.data; + }), + }; +} + +export function useAgentConfigApi(): AgentConfigApi { + return createHttpAgentConfigApi(useAuthenticatedInstance()); +} diff --git a/src/composables/axios/index.ts b/src/composables/axios/index.ts index 451e28ad..19ae6a16 100644 --- a/src/composables/axios/index.ts +++ b/src/composables/axios/index.ts @@ -221,10 +221,20 @@ const decamelizeKeys = (data: any, headers: AxiosHeaders) => { return JSON.stringify(_decamelizeKeys(data, { separator: '-', deep: true })); }; +// JSON body WITHOUT key transformation, for opaque documents whose keys must be sent verbatim +// (e.g. agent config overlays: snake_case `policy_data`, plugin names and config keys). +// It sets Content-Type explicitly: with a custom transformRequest axios no longer sets it, +// and a bare JSON.stringify would go out as text/plain (agent remote-config design R13). +const jsonBody = (data: unknown, headers: AxiosHeaders) => { + headers.set('Content-Type', 'application/json'); + return JSON.stringify(data); +}; + export { useAuthenticatedInstance, useGuestInstance, useDataApi, useGuestApi, decamelizeKeys, + jsonBody, }; diff --git a/src/composables/usePermissions.ts b/src/composables/usePermissions.ts index 6cceb4ac..15ba8e14 100644 --- a/src/composables/usePermissions.ts +++ b/src/composables/usePermissions.ts @@ -19,9 +19,13 @@ export function usePermissions() { store.can(RESOURCES.ADMIN, ACTIONS.MANAGE), ); + // Whether /me/permissions has hydrated (can() is optimistic until then). + const loaded = computed(() => store.loaded); + return { can, canManageAdmin, + loaded, hydrate: () => store.hydrate(), permissionTooltip, RESOURCES, diff --git a/src/constants/permissions.ts b/src/constants/permissions.ts index 71cff12f..cce5bbc5 100644 --- a/src/constants/permissions.ts +++ b/src/constants/permissions.ts @@ -75,6 +75,11 @@ export const ACTIONS = { // action-only resources TRIGGER: 'trigger', EXECUTE: 'execute', + // agent remote configuration (design §7, R39/R40) + CONFIGURE: 'configure', + // The agent service account fetching its overlay / reporting. Mirrored from the manifest; + // the UI never checks it. + SYNC: 'sync', } as const; export type ResourceName = (typeof RESOURCES)[keyof typeof RESOURCES]; @@ -160,6 +165,7 @@ const ACTION_VERBS: Partial> = { [ACTIONS.USERS_MANAGE]: 'manage', [ACTIONS.SSO_MANAGE]: 'manage', [ACTIONS.SETTINGS_MANAGE]: 'manage', + [ACTIONS.CONFIGURE]: 'configure', }; // Tooltip shown on a disabled action the user lacks permission for. From 2ff8bc1eb68342d0f63c00e636b997c1f56e1912 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:58:52 -0300 Subject: [PATCH 2/3] feat(agent-config): page through the agent's instances - listInstances sends ?page&limit (default 1 and 25, the API's max) and returns one page with its meta (page, limit, total, totalPages and the fleet-wide counts) - listAllInstances (instancePages.ts) is the one way to load every instance: pages in order until the last one, at most MAX_INSTANCE_PAGES (4 pages = 100 instances; a worst-case summary is about 3 MiB), each instance once. Past the cap, or when the list moved while paging, the result is marked partial Co-Authored-By: Claude Opus 5.5 --- .../__tests__/instancePages.spec.ts | 98 +++++++++++++++++++ .../__tests__/useAgentConfigApi.spec.ts | 24 ++++- src/composables/agent-config/instancePages.ts | 56 +++++++++++ .../agent-config/useAgentConfigApi.ts | 7 +- 4 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 src/composables/agent-config/__tests__/instancePages.spec.ts create mode 100644 src/composables/agent-config/instancePages.ts diff --git a/src/composables/agent-config/__tests__/instancePages.spec.ts b/src/composables/agent-config/__tests__/instancePages.spec.ts new file mode 100644 index 00000000..3e193002 --- /dev/null +++ b/src/composables/agent-config/__tests__/instancePages.spec.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { AgentConfigApi, InstancesPageQuery } from '../api-types'; +import type { AgentInstanceSummary } from '@/types/agent-config'; +import { + INSTANCE_PAGE_LIMIT, + MAX_INSTANCE_PAGES, + listAllInstances, +} from '../instancePages'; +import { instancesMixed } from './fixtures'; + +/** A fleet of `n` instances served `limit` per page, as the API does. */ +function fleet(n: number) { + const rows: AgentInstanceSummary[] = Array.from({ length: n }, (_, i) => ({ + ...instancesMixed.items[0], + instanceId: `i${i + 1}`, + hostname: `ip-${i + 1}`, + })); + const listInstances = vi.fn( + async (_agentId: string, q: InstancesPageQuery = {}) => { + const page = q.page ?? 1; + const limit = q.limit ?? INSTANCE_PAGE_LIMIT; + return { + items: rows.slice((page - 1) * limit, page * limit), + meta: { + desiredRevision: 7, + counts: { ...instancesMixed.meta.counts, total: n }, + page, + limit, + total: n, + totalPages: Math.max(1, Math.ceil(n / limit)), + }, + }; + }, + ); + return { api: { listInstances } as unknown as AgentConfigApi, listInstances }; +} + +describe('listAllInstances', () => { + it('one page: exactly one request', async () => { + const { api, listInstances } = fleet(1); + const all = await listAllInstances(api, 'a1'); + expect(listInstances).toHaveBeenCalledTimes(1); + expect(listInstances).toHaveBeenCalledWith('a1', { page: 1, limit: 25 }); + expect(all.items.map((i) => i.instanceId)).toEqual(['i1']); + expect(all.partial).toBe(false); + }); + + it('reads the pages in order until the last one', async () => { + const { api, listInstances } = fleet(60); + const all = await listAllInstances(api, 'a1'); + expect(listInstances.mock.calls.map((c) => c[1])).toEqual([ + { page: 1, limit: 25 }, + { page: 2, limit: 25 }, + { page: 3, limit: 25 }, + ]); + expect(all.items).toHaveLength(60); + expect(all.meta.totalPages).toBe(3); + expect(all.partial).toBe(false); + }); + + it(`stops at the cap (${MAX_INSTANCE_PAGES} pages) and says the result is partial`, async () => { + const { api, listInstances } = fleet(130); + const all = await listAllInstances(api, 'a1'); + expect(listInstances).toHaveBeenCalledTimes(MAX_INSTANCE_PAGES); + expect(all.items).toHaveLength(MAX_INSTANCE_PAGES * INSTANCE_PAGE_LIMIT); + expect(all.meta.counts.total).toBe(130); + expect(all.partial).toBe(true); + }); + + it('keeps an instance once when pages shift, and then counts the list as partial', async () => { + const { api, listInstances } = fleet(30); + const page1 = await listInstances('a1', { page: 1 }); + listInstances.mockClear(); + // Page 2 repeats the last row of page 1 (an instance reported meanwhile): i26 is missed. + listInstances.mockResolvedValueOnce(page1).mockResolvedValueOnce({ + ...page1, + items: [page1.items[24]], + meta: { ...page1.meta, page: 2 }, + }); + const all = await listAllInstances(api, 'a1'); + expect(all.items).toHaveLength(25); + expect(all.partial).toBe(true); + }); + + it('an unpaginated API (no totalPages) is one page', async () => { + const listInstances = vi.fn().mockResolvedValue({ + items: instancesMixed.items, + meta: { desiredRevision: 7, counts: instancesMixed.meta.counts }, + }); + const all = await listAllInstances( + { listInstances } as unknown as AgentConfigApi, + 'a1', + ); + expect(listInstances).toHaveBeenCalledTimes(1); + expect(all.items).toHaveLength(7); + expect(all.partial).toBe(false); + }); +}); diff --git a/src/composables/agent-config/__tests__/useAgentConfigApi.spec.ts b/src/composables/agent-config/__tests__/useAgentConfigApi.spec.ts index 873a5bee..ed3f79fa 100644 --- a/src/composables/agent-config/__tests__/useAgentConfigApi.spec.ts +++ b/src/composables/agent-config/__tests__/useAgentConfigApi.spec.ts @@ -133,7 +133,28 @@ describe('useAgentConfigApi (HTTP client)', () => { expect(config.transformRequest).toEqual([jsonBody]); }); - it('listInstances and getInstance: stop paths', async () => { + it('listInstances and getInstance: page query, meta and stop paths', async () => { + // As the camelcase interceptor leaves it: page fields beside the fleet-wide counts. + const meta = { + desiredRevision: 7, + counts: { total: 60, fresh: 58, stale: 2, inSync: 50, outOfSync: 8 }, + page: 2, + limit: 25, + total: 60, + totalPages: 3, + }; + get.mockResolvedValueOnce({ + status: 200, + data: { data: [{ instanceId: 'i26' }], meta }, + }); + await expect( + api.listInstances('a1', { page: 2, limit: 25 }), + ).resolves.toEqual({ items: [{ instanceId: 'i26' }], meta }); + expect(get).toHaveBeenLastCalledWith('/api/admin/agents/a1/instances', { + params: { page: 2, limit: 25 }, + camelcaseStopPaths: STOP_PATHS.instances, + }); + // Without a query: the first page of 25. get.mockResolvedValueOnce({ status: 200, data: { data: [], meta: { desiredRevision: 0 } }, @@ -143,6 +164,7 @@ describe('useAgentConfigApi (HTTP client)', () => { meta: { desiredRevision: 0 }, }); expect(get).toHaveBeenLastCalledWith('/api/admin/agents/a1/instances', { + params: { page: 1, limit: 25 }, camelcaseStopPaths: STOP_PATHS.instances, }); get.mockResolvedValueOnce({ diff --git a/src/composables/agent-config/instancePages.ts b/src/composables/agent-config/instancePages.ts new file mode 100644 index 00000000..5955dac6 --- /dev/null +++ b/src/composables/agent-config/instancePages.ts @@ -0,0 +1,56 @@ +// The agent instance list is paginated (api#476/#483: ?page&limit, limit max 25, last seen +// first; meta.counts cover every instance). Views that need EVERY instance (field access over +// the fleet, the instance files the draft is computed against) load it through +// listAllInstances, which stops at a hard page cap and says when the result is partial. + +import type { AgentConfigApi, InstancesList } from './api-types'; + +/** The API's page size for instances, and its maximum. */ +export const INSTANCE_PAGE_LIMIT = 25; + +/** + * At most this many pages (100 instances) are loaded for one agent. A worst-case instance + * summary is about 3 MiB (warnings, unsafe changes, plugins, remote_config), so this bounds a + * load to about 300 MiB in the worst case and a few hundred KiB in practice, while agents are + * expected to run one instance each. Past it the list is partial. + */ +export const MAX_INSTANCE_PAGES = 4; + +export interface AllInstances extends InstancesList { + /** Not every instance is loaded (more pages than the cap, or the list moved while paging). */ + partial: boolean; +} + +/** + * Every instance of `agentId`, page by page in order until the last page or `maxPages`. Pages + * can shift while they are read (the order is last seen first), so an instance is kept once. + * `meta` is the last page's (its counts are the freshest). + */ +export async function listAllInstances( + api: AgentConfigApi, + agentId: string, + maxPages = MAX_INSTANCE_PAGES, +): Promise { + let res = await api.listInstances(agentId, { + page: 1, + limit: INSTANCE_PAGE_LIMIT, + }); + const byId = new Map(res.items.map((i) => [i.instanceId, i])); + let page = 1; + while (page < (res.meta.totalPages ?? 1) && page < maxPages) { + page++; + res = await api.listInstances(agentId, { + page, + limit: INSTANCE_PAGE_LIMIT, + }); + for (const i of res.items) + if (!byId.has(i.instanceId)) byId.set(i.instanceId, i); + } + const items = Array.from(byId.values()); + const total = res.meta.counts?.total ?? res.meta.total ?? items.length; + return { + items, + meta: res.meta, + partial: page < (res.meta.totalPages ?? 1) || items.length < total, + }; +} diff --git a/src/composables/agent-config/useAgentConfigApi.ts b/src/composables/agent-config/useAgentConfigApi.ts index 069397be..758e4078 100644 --- a/src/composables/agent-config/useAgentConfigApi.ts +++ b/src/composables/agent-config/useAgentConfigApi.ts @@ -19,6 +19,7 @@ import type { SaveResult, } from '@/types/agent-config'; import { AgentConfigApiError, type AgentConfigApi } from './api-types'; +import { INSTANCE_PAGE_LIMIT } from './instancePages'; import { maskedPointers } from '@/utils/agent-config/validation'; export * from './api-types'; @@ -271,12 +272,16 @@ export function createHttpAgentConfigApi( ); return saveResult(res); }), - listInstances: (agentId) => + listInstances: (agentId, query = {}) => call('listInstances', async () => { const res = await instance.get<{ data: AgentInstanceSummary[]; meta: InstancesMeta; }>(`${base(agentId)}/instances`, { + params: { + page: query.page ?? 1, + limit: query.limit ?? INSTANCE_PAGE_LIMIT, + }, camelcaseStopPaths: STOP_PATHS.instances, }); return { items: res.data.data ?? [], meta: res.data.meta }; From 17842c85b56aa327ae5738e51db88a035839dc51 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:00:53 -0300 Subject: [PATCH 3/3] fix(agent-config): an instance list is partial only past the cap or when it moved listAllInstances marks the result partial when it stops at the page cap, or when a row comes back twice (the list moved under the pages, so another row was skipped), instead of comparing the rows with counts.total. Co-Authored-By: Claude Opus 5.5 --- src/composables/agent-config/instancePages.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/composables/agent-config/instancePages.ts b/src/composables/agent-config/instancePages.ts index 5955dac6..0d738ff9 100644 --- a/src/composables/agent-config/instancePages.ts +++ b/src/composables/agent-config/instancePages.ts @@ -36,6 +36,8 @@ export async function listAllInstances( limit: INSTANCE_PAGE_LIMIT, }); const byId = new Map(res.items.map((i) => [i.instanceId, i])); + // A row seen twice means the list moved under the pages: some other row was skipped. + let shifted = false; let page = 1; while (page < (res.meta.totalPages ?? 1) && page < maxPages) { page++; @@ -43,14 +45,14 @@ export async function listAllInstances( page, limit: INSTANCE_PAGE_LIMIT, }); - for (const i of res.items) - if (!byId.has(i.instanceId)) byId.set(i.instanceId, i); + for (const i of res.items) { + if (byId.has(i.instanceId)) shifted = true; + else byId.set(i.instanceId, i); + } } - const items = Array.from(byId.values()); - const total = res.meta.counts?.total ?? res.meta.total ?? items.length; return { - items, + items: Array.from(byId.values()), meta: res.meta, - partial: page < (res.meta.totalPages ?? 1) || items.length < total, + partial: shifted || page < (res.meta.totalPages ?? 1), }; }