diff --git a/src/utils/agent-config/__tests__/json-pointer.spec.ts b/src/utils/agent-config/__tests__/json-pointer.spec.ts new file mode 100644 index 00000000..5a7ed3d4 --- /dev/null +++ b/src/utils/agent-config/__tests__/json-pointer.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { + formatPointer, + getAt, + hasAt, + isPrefix, + parsePointer, + pointer, +} from '../json-pointer'; + +describe('json-pointer', () => { + it.each([ + ['', []], + [ + '/plugins/local-ssh/config/port', + ['plugins', 'local-ssh', 'config', 'port'], + ], + ['/plugins/p/labels/team~1owner', ['plugins', 'p', 'labels', 'team/owner']], + ['/x/~0tilde~1slash', ['x', '~tilde/slash']], + ['/a/', ['a', '']], + ])('parses %s', (ptr, tokens) => { + expect(parsePointer(ptr)).toEqual(tokens); + expect(formatPointer(tokens)).toBe(ptr); + }); + + it('rejects pointers without a leading slash', () => { + expect(() => parsePointer('plugins')).toThrow(); + }); + + it('pointer() escapes tokens', () => { + expect(pointer('plugins', 'p', 'policy_data', 'dir/x')).toBe( + '/plugins/p/policy_data/dir~1x', + ); + }); + + const doc = { a: { b: null, c: [10, { d: 1 }] }, e: 0 }; + it.each([ + ['/a/b', null, true], + ['/a/x', undefined, false], + ['/a/c/0', 10, true], + ['/a/c/1/d', 1, true], + ['/a/c/2', undefined, false], + ['/a/b/z', undefined, false], + ['/e', 0, true], + ['', doc, true], + ])('getAt/hasAt %s', (ptr, value, present) => { + expect(getAt(doc, ptr)).toEqual(value); + expect(hasAt(doc, ptr)).toBe(present); + }); + + it.each([ + ['/plugins/a', '/plugins/a/config', true], + ['/plugins/a', '/plugins/a', true], + ['/plugins/a', '/plugins/ab', false], + ['', '/x', true], + ['/plugins/a/config', '/plugins/a', false], + ])('isPrefix(%s, %s)', (a, b, expected) => { + expect(isPrefix(a, b)).toBe(expected); + }); +}); diff --git a/src/utils/agent-config/__tests__/merge-patch.spec.ts b/src/utils/agent-config/__tests__/merge-patch.spec.ts new file mode 100644 index 00000000..7da083fc --- /dev/null +++ b/src/utils/agent-config/__tests__/merge-patch.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { clone, deepEqual, mergePatch } from '../merge-patch'; + +describe('mergePatch (RFC 7396 Appendix A)', () => { + const cases: [unknown, unknown, unknown][] = [ + [{ a: 'b' }, { a: 'c' }, { a: 'c' }], + [{ a: 'b' }, { b: 'c' }, { a: 'b', b: 'c' }], + [{ a: 'b' }, { a: null }, {}], + [{ a: 'b', b: 'c' }, { a: null }, { b: 'c' }], + [{ a: ['b'] }, { a: 'c' }, { a: 'c' }], + [{ a: 'c' }, { a: ['b'] }, { a: ['b'] }], + [{ a: { b: 'c' } }, { a: { b: 'd', c: null } }, { a: { b: 'd' } }], + [{ a: [{ b: 'c' }] }, { a: [1] }, { a: [1] }], + [ + ['a', 'b'], + ['c', 'd'], + ['c', 'd'], + ], + [{ a: 'b' }, ['c'], ['c']], + [{ a: 'foo' }, null, null], + [{ a: 'foo' }, 'bar', 'bar'], + [{ e: null }, { a: 1 }, { e: null, a: 1 }], + [[1, 2], { a: 'b', c: null }, { a: 'b' }], + [{}, { a: { bb: { ccc: null } } }, { a: { bb: {} } }], + ]; + + it.each(cases)('%j + %j', (target, patch, expected) => { + expect(mergePatch(target, patch)).toEqual(expected); + }); + + it('never mutates its inputs', () => { + const target = { a: { b: 1 }, c: [1] }; + const patch = { a: { b: null, d: 2 } }; + const t = clone(target); + const p = clone(patch); + mergePatch(target, patch); + expect(target).toEqual(t); + expect(patch).toEqual(p); + }); +}); + +describe('deepEqual / clone', () => { + it('ignores key order but not array order', () => { + expect(deepEqual({ a: 1, b: [1, 2] }, { b: [1, 2], a: 1 })).toBe(true); + expect(deepEqual({ b: [2, 1] }, { b: [1, 2] })).toBe(false); + expect(deepEqual({ a: undefined }, {})).toBe(false); + expect(deepEqual(null, {})).toBe(false); + }); + + it('clones deeply', () => { + const src = { a: { b: [{ c: 1 }] } }; + const copy = clone(src); + copy.a.b[0].c = 2; + expect(src.a.b[0].c).toBe(1); + }); +}); diff --git a/src/utils/agent-config/__tests__/overlay-ops.spec.ts b/src/utils/agent-config/__tests__/overlay-ops.spec.ts new file mode 100644 index 00000000..3b5d97da --- /dev/null +++ b/src/utils/agent-config/__tests__/overlay-ops.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { nullAt, setAt, unsetAt } from '../overlay-ops'; + +describe('overlay-ops', () => { + it('setAt creates intermediates and never mutates', () => { + const o = { plugins: { a: { schedule: 'x' } } }; + const next = setAt(o, '/plugins/a/config/port', '22'); + expect(next).toEqual({ + plugins: { a: { schedule: 'x', config: { port: '22' } } }, + }); + expect(o).toEqual({ plugins: { a: { schedule: 'x' } } }); + }); + + it('setAt replaces a null intermediate (un-remove a plugin)', () => { + const o = { plugins: { a: null } }; + expect(setAt(o, '/plugins/a/enabled', true)).toEqual({ + plugins: { a: { enabled: true } }, + }); + }); + + it('unsetAt removes the key and prunes empty parents up to depth 1', () => { + const o = { verbosity: 1, plugins: { a: { config: { port: '22' } } } }; + expect(unsetAt(o, '/plugins/a/config/port')).toEqual({ verbosity: 1 }); + }); + + it('unsetAt keeps non-empty parents and the root', () => { + expect( + unsetAt( + { plugins: { a: { config: { p: '1', q: '2' } } } }, + '/plugins/a/config/p', + ), + ).toEqual({ plugins: { a: { config: { q: '2' } } } }); + expect(unsetAt({ verbosity: 1 }, '/verbosity')).toEqual({}); + }); + + it('unsetAt of an absent key is a no-op copy', () => { + const o = { plugins: { a: { schedule: 'x' } } }; + expect(unsetAt(o, '/plugins/b/schedule')).toEqual(o); + }); + + it('nullAt writes an explicit null', () => { + expect(nullAt({}, '/plugins/a')).toEqual({ plugins: { a: null } }); + }); +}); + +describe('"__proto__" keys are data, never prototypes', () => { + it('setAt does not pollute Object.prototype and keeps the key', async () => { + const { setAt } = await import('../overlay-ops'); + const out = setAt( + { plugins: {} }, + '/plugins/__proto__/config/k', + 'v', + ) as Record; + expect(({} as Record).config).toBeUndefined(); + expect(Object.prototype.hasOwnProperty.call(out.plugins, '__proto__')).toBe( + true, + ); + }); + + it('clone and mergePatch preserve an own "__proto__" key', async () => { + const { clone, mergePatch } = await import('../merge-patch'); + const doc = JSON.parse('{"policy_data":{"__proto__":{"x":1}}}'); + expect(JSON.stringify(clone(doc))).toBe( + '{"policy_data":{"__proto__":{"x":1}}}', + ); + expect(JSON.stringify(mergePatch({}, doc))).toBe( + '{"policy_data":{"__proto__":{"x":1}}}', + ); + }); +}); diff --git a/src/utils/agent-config/__tests__/provenance.spec.ts b/src/utils/agent-config/__tests__/provenance.spec.ts new file mode 100644 index 00000000..f02caeab --- /dev/null +++ b/src/utils/agent-config/__tests__/provenance.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { pluginProvenance, provenanceOf } from '../provenance'; + +const base = { + verbosity: 0, + plugins: { a: { schedule: 'x', config: { k: 'v' } } }, +}; + +describe('provenance', () => { + it.each([ + ['/verbosity', {}, 'file'], + ['/verbosity', { verbosity: 1 }, 'overrides-file'], + ['/plugins/b', { plugins: { b: { source: 's' } } }, 'overlay'], + ['/plugins/a', { plugins: { a: null } }, 'removed-by-overlay'], + ['/plugins/a/schedule', { plugins: { a: null } }, 'removed-by-overlay'], + [ + '/plugins/a/config/k', + { plugins: { a: { config: { k: null } } } }, + 'removed-by-overlay', + ], + [ + '/plugins/a/config/new', + { plugins: { a: { config: { new: '1' } } } }, + 'overlay', + ], + ['/plugins/zzz', { plugins: { zzz: null } }, 'file'], + ])('%s with %j → %s', (ptr, overlay, expected) => { + expect(provenanceOf(ptr, base, overlay)).toBe(expected); + }); + + it('treats a null file value as present', () => { + const b = { policy_data: { k: null } }; + expect(provenanceOf('/policy_data/k', b, { policy_data: { k: 1 } })).toBe( + 'overrides-file', + ); + expect( + provenanceOf('/policy_data/k', b, { policy_data: { k: null } }), + ).toBe('removed-by-overlay'); + expect(provenanceOf('/policy_data/k', b, {})).toBe('file'); + }); + + it('classifies descendants of an array or scalar replacement by the replacement', () => { + const b = { items: [1], objs: [{ x: 1, y: 2 }], m: { a: { b: 1 } } }; + // A null inside a replacing array is data, not a deletion. + expect(provenanceOf('/items/0', b, { items: [null] })).toBe( + 'overrides-file', + ); + expect(provenanceOf('/objs/0/x', b, { objs: [{ x: 3 }] })).toBe( + 'overrides-file', + ); + expect(provenanceOf('/objs/0/y', b, { objs: [{ x: 3 }] })).toBe( + 'removed-by-overlay', + ); + expect(provenanceOf('/objs/1', b, { objs: [{ x: 3 }, 4] })).toBe('overlay'); + // A scalar replacing an object removes what was below it. + expect(provenanceOf('/m/a/b', b, { m: 'flat' })).toBe('removed-by-overlay'); + expect(provenanceOf('/m/a/b', b, { m: { a: 7 } })).toBe( + 'removed-by-overlay', + ); + }); + + it('plugin provenance', () => { + expect( + pluginProvenance('a', base, { plugins: { a: { schedule: 'y' } } }), + ).toBe('overrides-file'); + expect(pluginProvenance('a', base, {})).toBe('file'); + }); +}); diff --git a/src/utils/agent-config/__tests__/yaml.spec.ts b/src/utils/agent-config/__tests__/yaml.spec.ts new file mode 100644 index 00000000..7151e047 --- /dev/null +++ b/src/utils/agent-config/__tests__/yaml.spec.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest'; +import { parseYaml, toYaml } from '../yaml'; + +describe('yaml', () => { + it('keeps dates and yes/no as strings (CORE_SCHEMA)', () => { + const r = parseYaml('a: 2026-09-30\nb: yes\nc: 12\nd: true\n'); + expect(r).toEqual({ + ok: true, + value: { a: '2026-09-30', b: 'yes', c: 12, d: true }, + }); + }); + + it('reports the error line and column', () => { + const r = parseYaml('plugins:\n a: [1, 2\n'); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error.line).toBeGreaterThanOrEqual(1); + expect(r.error.column).toBeGreaterThanOrEqual(0); + expect(r.error.message).toBeTruthy(); + } + }); + + it('requires a mapping root; empty is {}', () => { + expect(parseYaml('- a\n- b\n')).toMatchObject({ + ok: false, + error: { message: 'Overlay must be a mapping' }, + }); + expect(parseYaml('')).toEqual({ ok: true, value: {} }); + expect(parseYaml('# only a comment\n')).toEqual({ ok: true, value: {} }); + expect(parseYaml('---\n# only a comment\n')).toEqual({ + ok: true, + value: {}, + }); + }); + + it('dumps multi-line strings as block scalars and keeps key order', () => { + const text = toYaml({ + z: 1, + policy_data: { motd: 'line one\n\nline two\n' }, + }); + expect(text).toContain('policy_data:'); + expect(text).toMatch(/motd: \|/); + expect(text.indexOf('z:')).toBeLessThan(text.indexOf('policy_data:')); + expect(parseYaml(text)).toEqual({ + ok: true, + value: { z: 1, policy_data: { motd: 'line one\n\nline two\n' } }, + }); + }); + + it('rejects aliases of mappings and sequences without expanding them', () => { + // 9 levels × 10 aliases: about 10^9 nodes once expanded. + const lines = ['a0: &a0 [x, x, x, x, x, x, x, x, x, x]']; + for (let i = 1; i <= 9; i++) { + const refs = Array.from({ length: 10 }, () => `*a${i - 1}`).join(', '); + lines.push(`a${i}: &a${i} [${refs}]`); + } + const started = Date.now(); + expect(parseYaml(lines.join('\n'))).toEqual({ + ok: false, + error: { message: 'YAML aliases are not supported', line: 0, column: 0 }, + }); + expect(Date.now() - started).toBeLessThan(1000); + + expect(parseYaml('a: &x {k: 1}\nb: *x\n')).toMatchObject({ + ok: false, + error: { message: 'YAML aliases are not supported' }, + }); + // An anchor without an alias, and an alias of a scalar (a copy), are harmless. + expect(parseYaml('a: &x {k: 1}\nb: &y 2\nc: *y\n')).toEqual({ + ok: true, + value: { a: { k: 1 }, b: 2, c: 2 }, + }); + }); + + it('rejects << merge keys', () => { + for (const text of [ + 'base: &b {k: 1}\nplugins:\n p:\n <<: *b\n', + 'plugins:\n p:\n <<: {k: 1}\n', + ]) { + expect(parseYaml(text)).toMatchObject({ + ok: false, + error: { message: 'YAML merge keys (<<) are not supported' }, + }); + } + }); + + it('rejects documents with too many values', () => { + const text = `a: [${Array.from({ length: 100_001 }, () => '1').join(',')}]`; + expect(parseYaml(text)).toMatchObject({ + ok: false, + error: { message: 'Document is too large' }, + }); + }); + + it('rejects an explicit null root instead of reading it as {}', () => { + for (const text of ['null\n', '~\n', '--- null\n', '# note\n~\n']) { + expect(parseYaml(text)).toMatchObject({ + ok: false, + error: { message: 'Overlay must be a mapping' }, + }); + } + }); + + it.each([ + ['policy_data:\n threshold: .inf\n', '.inf', 'is not a finite number', 1], + ['policy_data:\n t: -.Inf\n', '-.Inf', 'is not a finite number', 1], + ['policy_data:\n t: .nan\n', '.nan', 'is not a finite number', 1], + ['mode: 0644\n', '0644', 'is not a plain decimal number', 0], + ['x: [1, 0x1F]\n', '0x1F', 'is not a plain decimal number', 0], + ['x: 0o17\n', '0o17', 'is not a plain decimal number', 0], + ['x: +5\n', '+5', 'is not a plain decimal number', 0], + ['x: !!int 0644\n', '0644', 'is not a plain decimal number', 0], + [ + 'id: 12345678901234567890\n', + '12345678901234567890', + 'has more digits than can be stored exactly', + 0, + ], + ['0644: key\n', '0644', 'is not a plain decimal number', 0], + ])('rejects %j (%s)', (text, literal, reason, line) => { + expect(parseYaml(text)).toEqual({ + ok: false, + error: { + message: `${literal} ${reason}: quote it to keep it as text`, + line, + column: expect.any(Number), + }, + }); + }); + + it('points at the rejected number', () => { + const r = parseYaml('a: 1\nb:\n c: 0x10\n'); + expect(r).toMatchObject({ ok: false, error: { line: 2, column: 5 } }); + }); + + it('accepts plain decimal numbers and quoted look-alikes', () => { + expect( + parseYaml( + 'a: 0\nb: -12\nc: 1.5\nd: 1e3\ne: 1.0\nf: 9007199254740991\ng: "0644"\nh: ".inf"\n', + ), + ).toEqual({ + ok: true, + value: { + a: 0, + b: -12, + c: 1.5, + d: 1000, + e: 1, + f: 9007199254740991, + g: '0644', + h: '.inf', + }, + }); + // Out of float range: not a YAML float at all, so it stays the string typed. + expect(parseYaml('a: 1e999\n')).toEqual({ + ok: true, + value: { a: '1e999' }, + }); + }); + + it('renders an empty document as {}', () => { + expect(toYaml({})).toBe('{}\n'); + expect(toYaml(null)).toBe(''); + }); +}); diff --git a/src/utils/agent-config/json-pointer.ts b/src/utils/agent-config/json-pointer.ts new file mode 100644 index 00000000..00919619 --- /dev/null +++ b/src/utils/agent-config/json-pointer.ts @@ -0,0 +1,88 @@ +// RFC 6901 JSON Pointers (design R5). Every config path the API returns (`changes`, `unsafe`, +// 422 `overlay`/`instances` errors, `warnings`) is a pointer such as +// `/plugins/local-ssh/config/port` or `/plugins/local-ssh/labels/team~1owner`. + +import { isPlainObject } from './merge-patch'; + +/** Escapes one reference token (`~` → `~0`, `/` → `~1`). */ +export function escapeToken(token: string): string { + return token.replace(/~/g, '~0').replace(/\//g, '~1'); +} + +/** Unescapes one reference token (`~1` → `/`, then `~0` → `~`). */ +export function unescapeToken(token: string): string { + return token.replace(/~1/g, '/').replace(/~0/g, '~'); +} + +/** Splits a pointer into unescaped tokens. `""` is the root (no tokens). */ +export function parsePointer(ptr: string): string[] { + if (ptr === '') return []; + if (!ptr.startsWith('/')) { + throw new Error(`Invalid JSON Pointer "${ptr}": must start with "/"`); + } + return ptr.slice(1).split('/').map(unescapeToken); +} + +/** Joins unescaped tokens into a pointer. */ +export function formatPointer(tokens: readonly string[]): string { + return tokens.map((t) => `/${escapeToken(t)}`).join(''); +} + +/** Builds a pointer from tokens (alias of formatPointer that reads well at call sites). */ +export function pointer(...tokens: string[]): string { + return formatPointer(tokens); +} + +function child( + container: unknown, + token: string, +): { found: boolean; value: unknown } { + if (Array.isArray(container)) { + if (!/^(0|[1-9]\d*)$/.test(token)) + return { found: false, value: undefined }; + const i = Number(token); + return i < container.length + ? { found: true, value: container[i] } + : { found: false, value: undefined }; + } + if ( + isPlainObject(container) && + Object.prototype.hasOwnProperty.call(container, token) + ) { + return { found: true, value: container[token] }; + } + return { found: false, value: undefined }; +} + +/** + * Value at `ptr`: `undefined` when absent (or when an intermediate is not a container), and + * `null` for an explicit null. + */ +export function getAt(doc: unknown, ptr: string): unknown { + let cur: unknown = doc; + for (const token of parsePointer(ptr)) { + const next = child(cur, token); + if (!next.found) return undefined; + cur = next.value; + } + return cur; +} + +/** True when the key at `ptr` is present (even with a null value). */ +export function hasAt(doc: unknown, ptr: string): boolean { + let cur: unknown = doc; + for (const token of parsePointer(ptr)) { + const next = child(cur, token); + if (!next.found) return false; + cur = next.value; + } + return true; +} + +/** True when `a` equals `b` or is an ancestor of it (token-wise, not string-wise). */ +export function isPrefix(a: string, b: string): boolean { + const pa = parsePointer(a); + const pb = parsePointer(b); + if (pa.length > pb.length) return false; + return pa.every((t, i) => t === pb[i]); +} diff --git a/src/utils/agent-config/merge-patch.ts b/src/utils/agent-config/merge-patch.ts new file mode 100644 index 00000000..2bca0403 --- /dev/null +++ b/src/utils/agent-config/merge-patch.ts @@ -0,0 +1,85 @@ +// RFC 7396 JSON Merge Patch, used for display only (effective-config previews, provenance, +// client-side diffs). The API and the agent own the real merge. + +export type PlainObject = Record; + +export function isPlainObject(value: unknown): value is PlainObject { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + (Object.getPrototypeOf(value) === Object.prototype || + Object.getPrototypeOf(value) === null) + ); +} + +/** + * Assigns `obj[key] = value` as an own data property. A plain assignment of "__proto__" + * (a legal JSON/YAML key) would change the object's prototype instead of storing the key. + */ +export function setOwn(obj: PlainObject, key: string, value: unknown): void { + if (key === '__proto__') { + Object.defineProperty(obj, key, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + } else { + obj[key] = value; + } +} + +/** Own property (never inherited, e.g. Object.prototype via "__proto__"). */ +export function getOwn(obj: PlainObject, key: string): unknown { + return Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined; +} + +/** Deep clone of a JSON value. */ +export function clone(value: T): T { + if (Array.isArray(value)) return value.map((v) => clone(v)) as unknown as T; + if (isPlainObject(value)) { + const out: PlainObject = {}; + for (const [k, v] of Object.entries(value)) setOwn(out, k, clone(v)); + return out as T; + } + return value; +} + +/** Structural equality of two JSON values (object key order is ignored). */ +export function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { + return false; + } + return a.every((v, i) => deepEqual(v, b[i])); + } + if (isPlainObject(a) && isPlainObject(b)) { + const ka = Object.keys(a); + const kb = Object.keys(b); + if (ka.length !== kb.length) return false; + return ka.every( + (k) => + Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]), + ); + } + return false; +} + +/** + * RFC 7396: objects merge recursively, arrays and scalars replace, `null` deletes. Never + * mutates its inputs. + */ +export function mergePatch(target: unknown, patch: unknown): T { + if (!isPlainObject(patch)) return clone(patch) as T; + const result: PlainObject = isPlainObject(target) ? clone(target) : {}; + for (const [key, value] of Object.entries(patch)) { + if (value === null) { + delete result[key]; + } else { + setOwn(result, key, mergePatch(getOwn(result, key), value)); + } + } + return result as T; +} diff --git a/src/utils/agent-config/overlay-ops.ts b/src/utils/agent-config/overlay-ops.ts new file mode 100644 index 00000000..b35067eb --- /dev/null +++ b/src/utils/agent-config/overlay-ops.ts @@ -0,0 +1,79 @@ +// Pure edits of an overlay document at a JSON Pointer. Each returns a new document. +// +// R56 rule: "reset to file" = OMIT the key (unsetAt); `null` = delete the key from the +// effective config so the agent default applies (nullAt). removeValueAt (policy-data-patch.ts) +// picks between the two against every known host file. + +import { + clone, + getOwn, + isPlainObject, + setOwn, + type PlainObject, +} from './merge-patch'; +import { hasAt, parsePointer } from './json-pointer'; + +/** + * Sets `value` at `ptr`. A `null` or non-object intermediate is replaced with `{}`, so an + * "un-remove" (editing a field of a plugin the overlay had nulled) still works. + */ +export function setAt( + overlay: T, + ptr: string, + value: unknown, +): T { + const tokens = parsePointer(ptr); + if (tokens.length === 0) { + return (isPlainObject(value) ? clone(value) : {}) as T; + } + const root: PlainObject = isPlainObject(overlay) + ? clone(overlay as PlainObject) + : {}; + let cur = root; + for (const token of tokens.slice(0, -1)) { + // Own properties only: "__proto__" must never walk into Object.prototype. + if (!isPlainObject(getOwn(cur, token))) setOwn(cur, token, {}); + cur = getOwn(cur, token) as PlainObject; + } + setOwn(cur, tokens[tokens.length - 1], clone(value)); + return root as T; +} + +/** + * Removes the key at `ptr` (so the file value applies again) and prunes parent objects that + * became empty, up to depth 1 (a top-level key such as `plugins` is removed when empty; the + * root is kept). An empty `plugins.

: {}` would otherwise still create an empty plugin + * under RFC 7396. + */ +export function unsetAt(overlay: T, ptr: string): T { + const tokens = parsePointer(ptr); + if (tokens.length === 0) return {} as T; + if (!hasAt(overlay, ptr)) return clone(overlay); + const root = clone(overlay as unknown as PlainObject); + const chain: PlainObject[] = [root]; + let cur = root; + for (const token of tokens.slice(0, -1)) { + const next = getOwn(cur, token); + if (!isPlainObject(next)) return root as T; + chain.push(next); + cur = next; + } + delete cur[tokens[tokens.length - 1]]; + // Prune empty ancestors from the deepest up to (and including) depth 1. + for (let depth = tokens.length - 1; depth >= 1; depth--) { + const parent = chain[depth - 1]; + const key = tokens[depth - 1]; + const obj = chain[depth]; + if (Object.keys(obj).length === 0) { + delete parent[key]; + } else { + break; + } + } + return root as T; +} + +/** Writes an explicit `null` (RFC 7396 delete) at `ptr`. */ +export function nullAt(overlay: T, ptr: string): T { + return setAt(overlay, ptr, null); +} diff --git a/src/utils/agent-config/provenance.ts b/src/utils/agent-config/provenance.ts new file mode 100644 index 00000000..af5f0bfa --- /dev/null +++ b/src/utils/agent-config/provenance.ts @@ -0,0 +1,73 @@ +// Where an effective value comes from (design §6.1): the agent's local file, the overlay, or +// the overlay overriding / removing a file value. Computed client-side from an instance's +// base and the overlay of the revision that instance runs (U1.4). + +import { + getAt, + hasAt, + parsePointer, + formatPointer, + pointer, +} from './json-pointer'; +import { isPlainObject } from './merge-patch'; + +export type Provenance = + | 'file' + | 'overlay' + | 'overrides-file' + | 'removed-by-overlay'; + +/** + * What the overlay does at `ptr` under RFC 7396. Walking down from the root, a null is a + * deletion and an object merges, but an array or scalar replaces the whole subtree: below it, + * nulls are data, not deletions, and a pointer is there only if the replacement has it. + */ +function overlayEffect( + overlay: unknown, + ptr: string, +): 'none' | 'deleted' | 'set' | 'replaced-without' { + const tokens = parsePointer(ptr); + for (let i = 1; i <= tokens.length; i++) { + const p = formatPointer(tokens.slice(0, i)); + if (!hasAt(overlay, p)) return 'none'; + const v = getAt(overlay, p); + if (v === null) return 'deleted'; + if (i < tokens.length && !isPlainObject(v)) { + return hasAt(overlay, ptr) ? 'set' : 'replaced-without'; + } + } + return 'set'; +} + +export function provenanceOf( + ptr: string, + base: unknown, + overlay: unknown, +): Provenance { + // A null file value is still a member of the file. + const inBase = hasAt(base, ptr); + switch (overlayEffect(overlay, ptr)) { + case 'deleted': + case 'replaced-without': + return inBase ? 'removed-by-overlay' : 'file'; + case 'set': + return inBase ? 'overrides-file' : 'overlay'; + default: + return 'file'; + } +} + +export function pluginProvenance( + name: string, + base: unknown, + overlay: unknown, +): Provenance { + return provenanceOf(pointer('plugins', name), base, overlay); +} + +export const PROVENANCE_LABELS: Record = { + file: 'file', + overlay: 'overlay', + 'overrides-file': 'overlay (overrides file)', + 'removed-by-overlay': 'removed by overlay', +}; diff --git a/src/utils/agent-config/yaml.ts b/src/utils/agent-config/yaml.ts new file mode 100644 index 00000000..b43e8ded --- /dev/null +++ b/src/utils/agent-config/yaml.ts @@ -0,0 +1,192 @@ +// YAML rendering and parsing of config documents. CORE_SCHEMA everywhere (design 12.6), so +// timestamps stay strings and `yes`/`no` are not booleans. + +import * as jsYaml from 'js-yaml'; +import { CORE_SCHEMA, dump, load, Type, YAMLException } from 'js-yaml'; +import { isPlainObject } from './merge-patch'; + +export function toYaml(doc: unknown): string { + if (doc === undefined || doc === null) return ''; + if (isPlainObject(doc) && Object.keys(doc).length === 0) return '{}\n'; + return dump(doc, { + lineWidth: -1, + noRefs: true, + sortKeys: false, + schema: CORE_SCHEMA, + }); +} + +export interface YamlError { + message: string; + /** 0-based line. */ + line: number; + /** 0-based column. */ + column: number; +} + +export type ParseYamlResult = + | { ok: true; value: Record } + | { ok: false; error: YamlError }; + +/** A number as JSON writes it; anything else (0644, 0x1F, 0o17, +5, .inf) is ambiguous. */ +const DECIMAL_RE = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][-+]?[0-9]+)?$/; + +/** A YAML number that would not be saved as typed; parseYaml reports the first one. */ +class RejectedNumber { + line = 0; + column = 0; + constructor( + readonly text: string, + readonly reason: string, + ) {} +} + +/** + * Why a number scalar would not be saved as typed, or ''. JSON has no Infinity / NaN (they are + * written as null, an RFC 7396 delete), integers above 2^53 lose digits, and CORE_SCHEMA reads + * 0644, 0x1F and 0o17 as 644, 31 and 15 where the agent's YAML reader may not. + */ +function numberProblem(text: string, value: number): string { + if (!Number.isFinite(value)) return 'is not a finite number'; + if (!DECIMAL_RE.test(text)) return 'is not a plain decimal number'; + if ( + Number.isInteger(value) && + !Number.isSafeInteger(value) && + !/[.eE]/.test(text) + ) { + return 'has more digits than can be stored exactly'; + } + return ''; +} + +/** What the current parseYaml call has seen (load is synchronous). */ +let parsing: { rejected: RejectedNumber[]; content: boolean } | null = null; + +/** CORE_SCHEMA's int / float, constructing a RejectedNumber for a value numberProblem rejects. */ +function checked(tag: string, t: Type): Type { + return new Type(tag, { + kind: 'scalar', + resolve: (data: string) => t.resolve(data), + construct: (data: string) => { + const value = t.construct(data); + const why = numberProblem(String(data), value); + if (!why) return value; + const r = new RejectedNumber(String(data), why); + parsing?.rejected.push(r); + return r; + }, + }); +} + +const { int, float } = ( + jsYaml as unknown as { types: { int: Type; float: Type } } +).types; +const OVERLAY_SCHEMA = CORE_SCHEMA.extend({ + implicit: [ + checked('tag:yaml.org,2002:int', int), + checked('tag:yaml.org,2002:float', float), + ], +}); + +/** + * Records whether the document has any node (comment-only text and an explicit `null` both + * load as null) and where each rejected number is (js-yaml marks only its own errors). + */ +function listener(eventType: string, state: jsYaml.State) { + if (eventType !== 'close' || !parsing) return; + if (state.kind) parsing.content = true; + if (!(state.result instanceof RejectedNumber)) return; + const r = state.result; + r.line = state.line; + r.column = Math.max(0, state.position - state.lineStart - r.text.length); +} + +/** More values than an overlay plausibly has; the walk stops there. */ +const MAX_NODES = 100_000; + +/** + * Why a loaded document is not accepted, or null. js-yaml has no alias limit and loads an alias + * of a mapping / sequence as a shared reference, so a few hundred bytes of nested aliases + * expand into billions of nodes for any later walk. A container reached twice IS an alias, so + * the check stops at the first one, before anything is expanded. `<<` merge keys are not part + * of CORE_SCHEMA and would be saved as a literal "<<" key. + */ +function unsupported(root: unknown): string | null { + const seen = new Set(); + const stack: object[] = []; + let nodes = 0; + const visit = (v: unknown): string | null => { + if (++nodes > MAX_NODES) return 'Document is too large'; + if (v === null || typeof v !== 'object') return null; + if (seen.has(v)) return 'YAML aliases are not supported'; + seen.add(v); + stack.push(v); + return null; + }; + let err = visit(root); + while (!err && stack.length) { + const v = stack.pop() as object; + if (!Array.isArray(v) && Object.prototype.hasOwnProperty.call(v, '<<')) { + return 'YAML merge keys (<<) are not supported'; + } + for (const child of Object.values(v)) { + err = visit(child); + if (err) break; + } + } + return err; +} + +/** + * Parses an overlay document. Empty (or comment-only) text is `{}`; a non-mapping root + * (explicit `null` / `~` included), a number that would not be saved as typed (quote it), an + * alias of a mapping or sequence, and a `<<` merge key are errors. + */ +export function parseYaml(text: string): ParseYamlResult { + if (text.trim() === '') return { ok: true, value: {} }; + let value: unknown; + const seen = { rejected: [] as RejectedNumber[], content: false }; + parsing = seen; + try { + value = load(text, { schema: OVERLAY_SCHEMA, listener }); + } catch (e) { + if (e instanceof YAMLException) { + return { + ok: false, + error: { + message: e.reason || e.message, + line: e.mark?.line ?? 0, + column: e.mark?.column ?? 0, + }, + }; + } + return { + ok: false, + error: { message: (e as Error).message ?? String(e), line: 0, column: 0 }, + }; + } finally { + parsing = null; + } + const bad = seen.rejected[0]; + if (bad) { + return { + ok: false, + error: { + message: `${bad.text} ${bad.reason}: quote it to keep it as text`, + line: bad.line, + column: bad.column, + }, + }; + } + // Only comments: no overlay. An explicit null (`null`, `~`) is not a mapping. + if (value == null && !seen.content) return { ok: true, value: {} }; + const why = unsupported(value); + if (why) return { ok: false, error: { message: why, line: 0, column: 0 } }; + if (!isPlainObject(value)) { + return { + ok: false, + error: { message: 'Overlay must be a mapping', line: 0, column: 0 }, + }; + } + return { ok: true, value }; +}