diff --git a/src/composables/agent-config/api-types.ts b/src/composables/agent-config/api-types.ts new file mode 100644 index 00000000..ef7ffd0f --- /dev/null +++ b/src/composables/agent-config/api-types.ts @@ -0,0 +1,102 @@ +// Shared types of the agent config client (kept apart from useAgentConfigApi.ts so components +// and tests can import them without pulling in the HTTP client). + +import type { + AgentConfigRevision, + AgentConfigRevisionSummary, + AgentInstanceDetail, + AgentInstanceSummary, + ConfigErrorBody, + ConfigPreview, + InstancesMeta, + OverlayDoc, + SaveConfigRequest, + SaveResult, +} from '@/types/agent-config'; + +export type AgentConfigErrorKind = + | 'conflict' + | 'invalid' + | 'forbidden' + | 'unsupported' + | 'too-large' + | 'network' + | 'other'; + +export class AgentConfigApiError extends Error { + readonly status?: number; + readonly kind: AgentConfigErrorKind; + readonly body?: ConfigErrorBody; + readonly currentRevision?: number; + + constructor(init: { + kind: AgentConfigErrorKind; + message: string; + status?: number; + body?: ConfigErrorBody; + currentRevision?: number; + }) { + super(init.message); + this.name = 'AgentConfigApiError'; + this.kind = init.kind; + this.status = init.status; + this.body = init.body; + this.currentRevision = init.currentRevision; + } +} + +export function isAgentConfigApiError(e: unknown): e is AgentConfigApiError { + return e instanceof AgentConfigApiError; +} + +export interface RevisionsPage { + items: AgentConfigRevisionSummary[]; + total: number; + totalPages: number; +} + +/** One page of an agent's instances (last seen first); `meta.counts` cover every instance. */ +export interface InstancesList { + items: AgentInstanceSummary[]; + meta: InstancesMeta; +} + +/** The instance list's page (1-based, default 1) and size (default and max 25). */ +export interface InstancesPageQuery { + page?: number; + limit?: number; +} + +export interface AgentConfigApi { + getConfig(agentId: string): Promise; + putConfig( + agentId: string, + body: SaveConfigRequest, + ifMatchRevision: number, + ): Promise; + preview( + agentId: string, + overlay: OverlayDoc, + signal?: AbortSignal, + ): Promise; + listRevisions( + agentId: string, + page: number, + limit: number, + ): Promise; + getRevision(agentId: string, rev: number): Promise; + revert( + agentId: string, + rev: number, + ifMatchRevision: number, + comment?: string, + ): Promise; + listInstances( + agentId: string, + query?: InstancesPageQuery, + ): Promise; + getInstance( + agentId: string, + instanceId: string, + ): Promise; +} diff --git a/src/utils/agent-config/__tests__/policy-data-patch.spec.ts b/src/utils/agent-config/__tests__/policy-data-patch.spec.ts new file mode 100644 index 00000000..84ff563f --- /dev/null +++ b/src/utils/agent-config/__tests__/policy-data-patch.spec.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from 'vitest'; +import type { ConfigDoc, OverlayDoc } from '@/types/agent-config'; +import { + applyOps, + diffOps, + removeValueAt, + setValue, +} from '../policy-data-patch'; +import { getAt } from '../json-pointer'; +import { mergePatch } from '../merge-patch'; + +const PD = '/plugins/p/policy_data'; +const base: ConfigDoc = { + plugins: { + p: { + source: 's', + policy_data: { + a: 1, + b: { c: 2, d: 'x' }, + token: '••••', + list: ['u1', 'u2', 'u3'], + }, + }, + }, +}; +const eff = (overlay: OverlayDoc) => + getAt(mergePatch(base, overlay), PD) as Record; +/** Edits the effective policy_data like the editors do: diff → minimal ops. */ +function edit( + overlay: OverlayDoc, + change: (pd: Record) => Record, + bases: ConfigDoc[] = [base], +) { + const before = eff(overlay); + return applyOps( + overlay, + diffOps(PD, before, change(structuredClone(before))), + bases, + PD, + ); +} + +describe('minimal policy_data patches', () => { + it('a scalar change records one entry at its pointer', () => { + const o = edit({}, (pd) => ({ ...pd, a: 5 })); + expect(o).toEqual({ plugins: { p: { policy_data: { a: 5 } } } }); + expect(eff(o).a).toBe(5); + }); + + it('a nested key records only that key', () => { + const o = edit({}, (pd) => { + (pd.b as Record).c = 3; + return pd; + }); + expect(o).toEqual({ plugins: { p: { policy_data: { b: { c: 3 } } } } }); + }); + + it('removing a file key writes null; removing an overlay-only key drops it', () => { + let o = edit({}, (pd) => { + delete pd.a; + return pd; + }); + expect(o).toEqual({ plugins: { p: { policy_data: { a: null } } } }); + o = edit(o, (pd) => ({ ...pd, extra: true })); + expect(getAt(o, `${PD}/extra`)).toBe(true); + o = edit(o, (pd) => { + delete pd.extra; + return pd; + }); + expect(o).toEqual({ plugins: { p: { policy_data: { a: null } } } }); + }); + + it('an array item change writes the whole array', () => { + const o = edit({}, (pd) => { + (pd.list as string[])[1] = 'U2'; + return pd; + }); + expect(o).toEqual({ + plugins: { p: { policy_data: { list: ['u1', 'U2', 'u3'] } } }, + }); + }); + + it('setting a value back to the file value removes the entry', () => { + let o = edit({}, (pd) => ({ ...pd, a: 5 })); + o = edit(o, (pd) => ({ ...pd, a: 1 })); + expect(o).toEqual({}); + o = edit({}, (pd) => { + (pd.list as string[])[0] = 'x'; + return pd; + }); + o = edit(o, (pd) => { + (pd.list as string[])[0] = 'u1'; + return pd; + }); + expect(o).toEqual({}); + }); + + it('…but keeps it while another host file differs', () => { + const other: ConfigDoc = { + plugins: { p: { source: 's', policy_data: { a: 7 } } }, + }; + const pinned = { plugins: { p: { policy_data: { a: 5 } } } }; + expect(setValue(pinned, `${PD}/a`, 1, [base, other], PD)).toEqual({ + plugins: { p: { policy_data: { a: 1 } } }, + }); + expect(setValue(pinned, `${PD}/a`, 1, [base], PD)).toEqual({}); + }); + + it('untouched masked values never reach the overlay', () => { + const o = edit({}, (pd) => ({ ...pd, a: 2 })); + expect(getAt(o, `${PD}/token`)).toBeUndefined(); + expect(diffOps(PD, eff({}), eff({}))).toEqual([]); + }); + + it('composes with draft / revision entries already in the overlay', () => { + const saved: OverlayDoc = { + verbosity: 1, + plugins: { + p: { schedule: '* * * * *', policy_data: { a: 9, b: { d: 'y' } } }, + }, + }; + const o = edit(saved, (pd) => { + (pd.b as Record).c = 4; + return pd; + }); + expect(o).toEqual({ + verbosity: 1, + plugins: { + p: { + schedule: '* * * * *', + policy_data: { a: 9, b: { d: 'y', c: 4 } }, + }, + }, + }); + }); + + it('works when the overlay deletes base keys (null) and brings keys back', () => { + // The overlay deleted `b`; adding `b` again only shows the new keys. + let o: OverlayDoc = { plugins: { p: { policy_data: { b: null } } } }; + o = edit(o, (pd) => ({ ...pd, b: { z: 1 } })); + expect(eff(o).b).toEqual({ z: 1 }); + expect(getAt(o, `${PD}/b`)).toEqual({ c: null, d: null, z: 1 }); + + // The whole policy_data deleted: a new key shows alone. + o = { plugins: { p: { policy_data: null } } }; + o = setValue(o, `${PD}/n`, 1, [base], PD); + expect(eff(o)).toEqual({ n: 1 }); + }); + + it('removeValueAt nulls file keys only', () => { + expect(removeValueAt({}, `${PD}/b/c`, [base])).toEqual({ + plugins: { p: { policy_data: { b: { c: null } } } }, + }); + expect( + removeValueAt({ plugins: { p: { policy_data: { q: 1 } } } }, `${PD}/q`, [ + base, + ]), + ).toEqual({}); + }); + + it('removeValueAt checks every host file and works outside policy_data', () => { + const other: ConfigDoc = { plugins: { extra: { source: 'e' } } }; + // Only another host's file has `extra`: still nulled. + expect(removeValueAt({}, '/plugins/extra', [base, other])).toEqual({ + plugins: { extra: null }, + }); + // No file has `neu`: the overlay entry (and its emptied parents) are dropped. + expect( + removeValueAt({ plugins: { neu: { source: 'n' } } }, '/plugins/neu', [ + base, + other, + ]), + ).toEqual({}); + }); + + it('keeps an emptied overlay-only object inside policy_data', () => { + let o = edit({}, (pd) => ({ ...pd, n: { k: 1 } })); + expect(getAt(o, `${PD}/n`)).toEqual({ k: 1 }); + o = edit(o, (pd) => ({ ...pd, n: {} })); + expect(o).toEqual({ plugins: { p: { policy_data: { n: {} } } } }); + expect(eff(o).n).toEqual({}); + + // Nested: only the emptied parent is kept, its ancestors come back with it. + o = edit({}, (pd) => ({ ...pd, m: { n: { k: 1 } } })); + o = edit(o, (pd) => ({ ...pd, m: { n: {} } })); + expect(eff(o).m).toEqual({ n: {} }); + }); + + it('prunes an emptied object every host file already has', () => { + // `b` is an object in the file: the overlay's `b: {}` would be a no-op. + let o = edit({}, (pd) => ({ ...pd, b: { c: 2, d: 'x', z: 1 } })); + expect(o).toEqual({ plugins: { p: { policy_data: { b: { z: 1 } } } } }); + o = edit(o, (pd) => ({ ...pd, b: { c: 2, d: 'x' } })); + expect(o).toEqual({}); + // With no host file loaded, an emptied object is kept. + expect( + removeValueAt( + { plugins: { p: { policy_data: { b: { z: 1 } } } } }, + `${PD}/b/z`, + [], + ), + ).toEqual({ plugins: { p: { policy_data: { b: {} } } } }); + }); + + it('diffOps: objects recurse, everything else is one set', () => { + expect( + diffOps( + PD, + { a: 1, b: { c: 1 }, l: [1], gone: 0 }, + { a: '1', b: { c: 1, n: 2 }, l: [1, 2], o: {} }, + ), + ).toEqual([ + { op: 'set', ptr: `${PD}/a`, value: '1' }, + { op: 'set', ptr: `${PD}/b/n`, value: 2 }, + { op: 'set', ptr: `${PD}/l`, value: [1, 2] }, + { op: 'remove', ptr: `${PD}/gone` }, + { op: 'set', ptr: `${PD}/o`, value: {} }, + ]); + }); +}); diff --git a/src/utils/agent-config/__tests__/policy-data.spec.ts b/src/utils/agent-config/__tests__/policy-data.spec.ts new file mode 100644 index 00000000..40163484 --- /dev/null +++ b/src/utils/agent-config/__tests__/policy-data.spec.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest'; +import { + containsMask, + envRefs, + envSegments, + getIn, + isMasked, + itemType, + jsonType, + parseNewValue, + parseScalar, + removeIn, + setIn, + startsCollapsed, +} from '../policy-data'; + +describe('policy-data helpers', () => { + it('jsonType names every JSON type', () => { + expect(['s', 1, true, null, {}, []].map(jsonType)).toEqual([ + 'string', + 'number', + 'boolean', + 'null', + 'object', + 'array', + ]); + }); + + it('setIn / removeIn keep keys verbatim and never mutate', () => { + const root = { + MaxTries: 3, + 'kebab-key': { snake_key: [1, 2, 3] }, + 'a/b~c': 'x', + }; + const frozen = JSON.stringify(root); + const a = setIn(root, ['kebab-key', 'snake_key', 1], 20); + expect(a['kebab-key'].snake_key).toEqual([1, 20, 3]); + const b = setIn(a, ['camelCase', 'Deep'], true); + expect(Object.keys(b)).toEqual([ + 'MaxTries', + 'kebab-key', + 'a/b~c', + 'camelCase', + ]); + expect(getIn(b, ['camelCase', 'Deep'])).toBe(true); + // Appending to an array: index = length. + expect( + getIn(setIn(b, ['kebab-key', 'snake_key', 3], 4), [ + 'kebab-key', + 'snake_key', + ]), + ).toEqual([1, 20, 3, 4]); + expect(removeIn(b, ['kebab-key', 'snake_key', 0])['kebab-key']).toEqual({ + snake_key: [20, 3], + }); + // Pointer tokens are strings: array indices still work. + expect(removeIn([1, 2, 3], ['1'])).toEqual([1, 3]); + expect(setIn([1, { a: 1 }], ['1', 'a'], 2)).toEqual([1, { a: 2 }]); + expect('a/b~c' in removeIn(b, ['a/b~c'])).toBe(false); + expect(JSON.stringify(root)).toBe(frozen); + + // "__proto__" is an ordinary key, never the prototype. + const proto = setIn({}, ['__proto__'], { polluted: true }); + expect(Object.prototype.hasOwnProperty.call(proto, '__proto__')).toBe(true); + expect(({} as Record).polluted).toBeUndefined(); + }); + + it('parseNewValue: the container type, else a JSON literal, else text', () => { + expect(parseNewValue('5', null)).toEqual({ value: 5, error: '' }); + expect(parseNewValue('[]', null).value).toEqual([]); + expect(parseNewValue('"5"', null).value).toBe('5'); + expect(parseNewValue('hello world', null).value).toBe('hello world'); + expect(parseNewValue('5', 'string').value).toBe('5'); + expect(parseNewValue('x', 'number').error).toBe('Enter a number'); + expect(parseNewValue('0x10', 'number').error).toBe('Enter a number'); + expect(itemType(['a', 'b'])).toBe('string'); + expect(itemType([1, 'b'])).toBeNull(); + expect(itemType([{}])).toBeNull(); + expect(itemType([])).toBeNull(); + }); + + it('parseNewValue rejects JSON numbers that would not be stored as typed', () => { + for (const t of [ + '1e999', + '-1e999', + '12345678901234567890', + '[1, 1e999]', + '{"a": {"b": 12345678901234567890}}', + ]) { + expect(parseNewValue(t, null)).toEqual({ + value: undefined, + error: 'Number too large; quote it to keep it as a string', + }); + } + expect(parseNewValue('"12345678901234567890"', null).value).toBe( + '12345678901234567890', + ); + expect(parseNewValue('[1.5, -9007199254740991]', null).value).toEqual([ + 1.5, -9007199254740991, + ]); + }); + + it('parseScalar validates numbers and booleans, keeps strings verbatim', () => { + expect(parseScalar(' 4.5 ', 'number')).toEqual({ value: 4.5, error: '' }); + expect(parseScalar('', 'number').error).toBe('Enter a number'); + expect(parseScalar('-1.5e3', 'number').value).toBe(-1500); + // JSON number grammar only: Number() would accept these. + for (const t of [ + '0x10', + '0b11', + '0o7', + '.5', + '5.', + '+1', + '01', + 'Infinity', + ]) { + expect(parseScalar(t, 'number').error).toBe('Enter a number'); + } + // Not stored as typed: Infinity, or an integer beyond 2^53. + expect(parseScalar('1e999', 'number').error).toBe('Number too large'); + expect(parseScalar('12345678901234567890', 'number').error).toBe( + 'Number too large', + ); + expect(parseScalar('9007199254740991', 'number').value).toBe( + Number.MAX_SAFE_INTEGER, + ); + expect(parseScalar('false', 'boolean')).toEqual({ + value: false, + error: '', + }); + expect(parseScalar(' spaced ', 'string').value).toBe(' spaced '); + expect(parseScalar('', 'object').value).toEqual({}); + }); + + it('recognises masked values and ${env:} references', () => { + expect(isMasked('••••')).toBe(true); + expect(isMasked('x')).toBe(false); + expect(containsMask(['a', { b: ['••••'] }])).toBe(true); + expect(containsMask({ a: ['x', 1, null, true] })).toBe(false); + expect(envRefs('a ${env:HOME} b ${env:_X1}')).toEqual(['HOME', '_X1']); + expect(envSegments('pre ${env:A} post')).toEqual([ + { text: 'pre ', env: false }, + { text: '${env:A}', env: true }, + { text: ' post', env: false }, + ]); + expect(envSegments('${env:1BAD}')).toEqual([ + { text: '${env:1BAD}', env: false }, + ]); + }); + + it('collapses deep or large containers', () => { + expect(startsCollapsed(0, { a: 1 })).toBe(false); + expect(startsCollapsed(2, { a: 1 })).toBe(true); + expect( + startsCollapsed( + 0, + Array.from({ length: 21 }, (_, i) => i), + ), + ).toBe(true); + }); +}); diff --git a/src/utils/agent-config/policy-data-patch.ts b/src/utils/agent-config/policy-data-patch.ts new file mode 100644 index 00000000..7054d4af --- /dev/null +++ b/src/utils/agent-config/policy-data-patch.ts @@ -0,0 +1,183 @@ +// Minimal merge patches for `plugins.

.policy_data` (R69): an edit records only what it +// changes, at the pointer it changes, and composes with the pending overlay like the other +// per-field edits: +// - objects recurse key by key: a changed / added value is written at its own pointer; +// - a removed key becomes `null` where a host's file has it (else the entry is dropped); +// - arrays cannot be patched partially (RFC 7396), so a changed array is written whole at +// the array's pointer; +// - setting a value back to what every known file has drops the overlay entry instead of +// pinning a no-op (the hosts' files apply again); +// - unchanged keys and untouched masked values ("••••") produce no entry at all. +// `bases` are the hosts' files (every loaded instance base). They differ between instances, so +// "back to the file value" means equal in all of them. + +import type { ConfigDoc, OverlayDoc } from '@/types/agent-config'; +import { + clone, + deepEqual, + isPlainObject, + setOwn, + type PlainObject, +} from './merge-patch'; +import { + escapeToken, + formatPointer, + getAt, + hasAt, + parsePointer, +} from './json-pointer'; +import { nullAt, setAt, unsetAt } from './overlay-ops'; +import { isPolicyDataPointer } from './config-diff'; + +export type PatchOp = + | { op: 'set'; ptr: string; value: unknown } + | { op: 'remove'; ptr: string }; + +/** + * The edits turning `before` into `after` at `ptr`: objects recurse key by key (an added or + * changed key is a `set`, a removed one a `remove`); anything else that differs (a scalar, an + * array, a type change) is one `set` of the whole value. + */ +export function diffOps( + ptr: string, + before: unknown, + after: unknown, +): PatchOp[] { + const out: PatchOp[] = []; + walk(ptr, before, after, out); + return out; +} + +function walk(ptr: string, before: unknown, after: unknown, out: PatchOp[]) { + if (isPlainObject(before) && isPlainObject(after)) { + const keys = new Set([...Object.keys(before), ...Object.keys(after)]); + for (const k of keys) { + const p = `${ptr}/${escapeToken(k)}`; + const inB = Object.prototype.hasOwnProperty.call(before, k); + const inA = Object.prototype.hasOwnProperty.call(after, k); + if (!inA) out.push({ op: 'remove', ptr: p }); + else if (!inB) out.push({ op: 'set', ptr: p, value: clone(after[k]) }); + else walk(p, before[k], after[k], out); + } + return; + } + if (!deepEqual(before, after)) + out.push({ op: 'set', ptr, value: clone(after) }); +} + +/** The pointers from `scope` (inclusive) down to the parent of `ptr`. */ +function ancestors(scope: string, ptr: string): string[] { + const s = parsePointer(scope); + const t = parsePointer(ptr); + const out: string[] = []; + for (let n = s.length; n < t.length; n++) + out.push(formatPointer(t.slice(0, n))); + return out; +} + +function baseKeys(bases: readonly ConfigDoc[], ptr: string): string[] { + const keys = new Set(); + for (const b of bases) { + const v = getAt(b, ptr); + if (isPlainObject(v)) Object.keys(v).forEach((k) => keys.add(k)); + } + return Array.from(keys); +} + +/** + * `value` as a patch that REPLACES what the files have at `ptr`: objects merge under RFC 7396, + * so a key some file has but `value` lacks is nulled (recursively for nested objects). + */ +function replacing( + bases: readonly ConfigDoc[], + ptr: string, + value: unknown, +): unknown { + if (!isPlainObject(value)) return clone(value); + const out: PlainObject = {}; + for (const k of baseKeys(bases, ptr)) { + if (!Object.prototype.hasOwnProperty.call(value, k)) setOwn(out, k, null); + } + for (const [k, v] of Object.entries(value)) { + setOwn(out, k, replacing(bases, `${ptr}/${escapeToken(k)}`, v)); + } + return out; +} + +/** + * Sets the EFFECTIVE value at `ptr` (under `scope`, e.g. a plugin's policy_data) to `value` + * with the smallest overlay edit. + */ +export function setValue( + overlay: T, + ptr: string, + value: unknown, + bases: readonly ConfigDoc[], + scope: string, +): T { + const above = ancestors(scope, ptr); + const nulled = above.some((a) => getAt(overlay, a) === null); + if ( + bases.length && + !nulled && + bases.every((b) => hasAt(b, ptr) && deepEqual(getAt(b, ptr), value)) + ) { + return unsetAt(overlay, ptr); + } + let out = overlay; + // A nulled ancestor (the overlay deletes that object): bring it back as an object that still + // deletes every file key, so only the new value appears. + for (const a of above) { + if (getAt(out, a) === null) { + const nulls: PlainObject = {}; + for (const k of baseKeys(bases, a)) setOwn(nulls, k, null); + out = setAt(out, a, nulls); + } + } + return setAt(out, ptr, replacing(bases, ptr, value)); +} + +/** + * Removes the key / value at `ptr` from the EFFECTIVE config (any overlay pointer, against + * every known host file): `null` where some file has it, else the overlay entry is dropped. + * + * Dropping an entry prunes parent objects it leaves empty (an empty `plugins.

: {}` would + * create a plugin under RFC 7396). Inside a plugin's policy_data, though, an empty object is + * data: an emptied parent is pruned only when every file already has an object there (so `{}` + * is a no-op), otherwise it is kept as `{}`. + */ +export function removeValueAt( + overlay: T, + ptr: string, + bases: readonly ConfigDoc[], +): T { + if (bases.some((b) => hasAt(b, ptr))) return nullAt(overlay, ptr); + const out = unsetAt(overlay, ptr); + const parent = formatPointer(parsePointer(ptr).slice(0, -1)); + if ( + isPolicyDataPointer(parent) && + isPlainObject(getAt(overlay, parent)) && + !hasAt(out, parent) && + !(bases.length && bases.every((b) => isPlainObject(getAt(b, parent)))) + ) { + return setAt(out, parent, {}); + } + return out; +} + +/** Applies `ops` in order (setValue / removeValueAt). */ +export function applyOps( + overlay: T, + ops: readonly PatchOp[], + bases: readonly ConfigDoc[], + scope: string, +): T { + let out = overlay; + for (const o of ops) { + out = + o.op === 'set' + ? setValue(out, o.ptr, o.value, bases, scope) + : removeValueAt(out, o.ptr, bases); + } + return out; +} diff --git a/src/utils/agent-config/policy-data.ts b/src/utils/agent-config/policy-data.ts new file mode 100644 index 00000000..78c88377 --- /dev/null +++ b/src/utils/agent-config/policy-data.ts @@ -0,0 +1,215 @@ +// Pure helpers of the structured `policy_data` view/editor (PolicyDataTree). Edits are +// written per pointer (policy-data-patch.ts). Keys are used verbatim (own properties only, +// "__proto__" included): never case-converted. A value keeps its JSON type when edited; a +// type change is done in the raw JSON view. + +import { REDACTED_MASK } from '@/types/agent-config'; +import { clone, getOwn, isPlainObject, setOwn } from './merge-patch'; +import { isStorableNumber, unstorableNumberPointers } from './validation'; + +export type JsonType = + | 'string' + | 'number' + | 'boolean' + | 'null' + | 'object' + | 'array'; + +export type DataPath = readonly (string | number)[]; + +export function jsonType(v: unknown): JsonType { + if (v === null || v === undefined) return 'null'; + if (Array.isArray(v)) return 'array'; + if (isPlainObject(v)) return 'object'; + if (typeof v === 'number') return 'number'; + if (typeof v === 'boolean') return 'boolean'; + return 'string'; +} + +export function isContainer(v: unknown): boolean { + return Array.isArray(v) || isPlainObject(v); +} + +/** The report mask (R25): the host keeps its own value unless a new one is typed. */ +export function isMasked(v: unknown): boolean { + return v === REDACTED_MASK; +} + +/** Whether `v` is, or contains at any depth, a masked value. */ +export function containsMask(v: unknown): boolean { + if (isMasked(v)) return true; + if (Array.isArray(v)) return v.some(containsMask); + return isPlainObject(v) && Object.values(v).some(containsMask); +} + +const ENV_REF = /\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g; + +/** The ${env:NAME} references in a string (agentconfig.EnvRefPattern), in order. */ +export function envRefs(s: string): string[] { + return Array.from(s.matchAll(ENV_REF), (m) => m[1]); +} + +/** A string split into plain text and ${env:NAME} parts, for display. */ +export function envSegments(s: string): { text: string; env: boolean }[] { + const out: { text: string; env: boolean }[] = []; + let last = 0; + for (const m of s.matchAll(ENV_REF)) { + if (m.index! > last) out.push({ text: s.slice(last, m.index), env: false }); + out.push({ text: m[0], env: true }); + last = m.index! + m[0].length; + } + if (last < s.length) out.push({ text: s.slice(last), env: false }); + return out; +} + +function childOf(container: unknown, key: string | number): unknown { + if (Array.isArray(container)) return container[Number(key)]; + return isPlainObject(container) ? getOwn(container, String(key)) : undefined; +} + +/** The value at `path` (undefined when absent). */ +export function getIn(root: unknown, path: DataPath): unknown { + let cur = root; + for (const k of path) cur = childOf(cur, k); + return cur; +} + +/** A copy of `root` with `value` at `path` (an index equal to the length appends). */ +export function setIn(root: T, path: DataPath, value: unknown): T { + if (!path.length) return clone(value) as T; + const [head, ...rest] = path; + if (Array.isArray(root)) { + const out = root.slice(); + out[Number(head)] = setIn(out[Number(head)], rest, value); + return out as T; + } + const out = isPlainObject(root) ? clone(root) : {}; + setOwn(out, String(head), setIn(getOwn(out, String(head)), rest, value)); + return out as T; +} + +/** + * A copy of `root` without the entry at `path`: an object key is dropped (the editor's target + * loses it, so the merge patch nulls it where the base has it); an array item is spliced out + * (arrays are replaced wholesale). + */ +export function removeIn(root: T, path: DataPath): T { + if (!path.length) return root; + const parentPath = path.slice(0, -1); + const key = path[path.length - 1]; + const parent = getIn(root, parentPath); + let next: unknown; + if (Array.isArray(parent)) { + next = parent.filter((_, i) => i !== Number(key)); + } else if (isPlainObject(parent)) { + next = clone(parent); + delete (next as Record)[String(key)]; + } else { + return root; + } + return parentPath.length ? setIn(root, parentPath, next) : (next as T); +} + +/** JSON's number grammar (RFC 8259): no hex / binary / octal, no `Infinity`, no `.5`. */ +const JSON_NUMBER_RE = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?$/; +const NUMBER_TOO_LARGE = 'Number too large'; + +/** Whether `v` (a JSON.parse result) holds a number that would not be stored as typed. */ +export function hasUnstorableNumber(v: unknown): boolean { + return unstorableNumberPointers(v).length > 0; +} + +/** + * Parses the text of a scalar editor as `type`. Strings are taken verbatim; numbers must be + * JSON numbers that are stored exactly (finite, integers within 2^53); booleans "true"/"false". + */ +export function parseScalar( + text: string, + type: JsonType, +): { value: unknown; error: string } { + switch (type) { + case 'number': { + const t = text.trim(); + if (!JSON_NUMBER_RE.test(t)) { + return { value: undefined, error: 'Enter a number' }; + } + const n = Number(t); + if (!isStorableNumber(n)) { + return { value: undefined, error: NUMBER_TOO_LARGE }; + } + return { value: n, error: '' }; + } + case 'boolean': + if (text !== 'true' && text !== 'false') { + return { value: undefined, error: 'Choose true or false' }; + } + return { value: text === 'true', error: '' }; + case 'null': + return { value: null, error: '' }; + case 'object': + return { value: {}, error: '' }; + case 'array': + return { value: [], error: '' }; + default: + return { value: text, error: '' }; + } +} + +/** + * A value typed into an "add" form: as `type` when the container dictates one (an array whose + * items share a scalar type), else as a JSON literal when it parses (5, true, null, [], {}, + * "quoted"), else as the plain string. + */ +export function parseNewValue( + text: string, + type: JsonType | null, +): { value: unknown; error: string } { + if (type === 'string' || type === 'number' || type === 'boolean') { + return parseScalar(text, type); + } + let value: unknown; + try { + value = JSON.parse(text); + } catch { + return { value: text, error: '' }; + } + // 1e999 parses as Infinity (saved as null, an RFC 7396 delete) and big integers lose digits. + if (hasUnstorableNumber(value)) { + return { + value: undefined, + error: `${NUMBER_TOO_LARGE}; quote it to keep it as a string`, + }; + } + return { value, error: '' }; +} + +/** The scalar type every item of `arr` shares, or null (mixed, containers, empty). */ +export function itemType(arr: readonly unknown[]): JsonType | null { + const types = new Set(arr.map(jsonType)); + if (types.size !== 1) return null; + const [t] = types; + return t === 'string' || t === 'number' || t === 'boolean' ? t : null; +} + +/** Display text of a scalar ('' for containers). */ +export function scalarText(v: unknown): string { + if (typeof v === 'string') return v; + if (v === null || v === undefined) return 'null'; + if (isContainer(v)) return ''; + return String(v); +} + +/** Containers deeper than this, or larger than COLLAPSE_SIZE, start collapsed. */ +export const COLLAPSE_DEPTH = 2; +export const COLLAPSE_SIZE = 20; +/** Children rendered before a "Show all" button. */ +export const CHILD_PAGE = 50; + +export function sizeOf(v: unknown): number { + if (Array.isArray(v)) return v.length; + return isPlainObject(v) ? Object.keys(v).length : 0; +} + +export function startsCollapsed(depth: number, v: unknown): boolean { + return depth >= COLLAPSE_DEPTH || sizeOf(v) > COLLAPSE_SIZE; +}