@moxxy/config 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/dist/config-writer.d.ts +30 -0
  3. package/dist/config-writer.d.ts.map +1 -0
  4. package/dist/config-writer.js +57 -0
  5. package/dist/config-writer.js.map +1 -0
  6. package/dist/define.d.ts +17 -0
  7. package/dist/define.d.ts.map +1 -0
  8. package/dist/define.js +18 -0
  9. package/dist/define.js.map +1 -0
  10. package/dist/index.d.ts +9 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +11 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/loader.d.ts +28 -0
  15. package/dist/loader.d.ts.map +1 -0
  16. package/dist/loader.js +210 -0
  17. package/dist/loader.js.map +1 -0
  18. package/dist/merge.d.ts +9 -0
  19. package/dist/merge.d.ts.map +1 -0
  20. package/dist/merge.js +45 -0
  21. package/dist/merge.js.map +1 -0
  22. package/dist/plugin-settings-schema.d.ts +24 -0
  23. package/dist/plugin-settings-schema.d.ts.map +1 -0
  24. package/dist/plugin-settings-schema.js +17 -0
  25. package/dist/plugin-settings-schema.js.map +1 -0
  26. package/dist/plugin.d.ts +21 -0
  27. package/dist/plugin.d.ts.map +1 -0
  28. package/dist/plugin.js +329 -0
  29. package/dist/plugin.js.map +1 -0
  30. package/dist/plugins-tree-schema.d.ts +444 -0
  31. package/dist/plugins-tree-schema.d.ts.map +1 -0
  32. package/dist/plugins-tree-schema.js +93 -0
  33. package/dist/plugins-tree-schema.js.map +1 -0
  34. package/dist/schema.d.ts +1200 -0
  35. package/dist/schema.d.ts.map +1 -0
  36. package/dist/schema.js +198 -0
  37. package/dist/schema.js.map +1 -0
  38. package/dist/user-config.d.ts +77 -0
  39. package/dist/user-config.d.ts.map +1 -0
  40. package/dist/user-config.js +265 -0
  41. package/dist/user-config.js.map +1 -0
  42. package/package.json +56 -0
  43. package/src/config-writer.test.ts +52 -0
  44. package/src/config-writer.ts +88 -0
  45. package/src/define.ts +19 -0
  46. package/src/index.ts +64 -0
  47. package/src/loader.test.ts +122 -0
  48. package/src/loader.ts +232 -0
  49. package/src/loader.yaml.test.ts +149 -0
  50. package/src/merge.test.ts +78 -0
  51. package/src/merge.ts +44 -0
  52. package/src/plugin-settings-schema.ts +18 -0
  53. package/src/plugin.test.ts +310 -0
  54. package/src/plugin.ts +360 -0
  55. package/src/plugins-tree-schema.test.ts +25 -0
  56. package/src/plugins-tree-schema.ts +103 -0
  57. package/src/schema.ts +218 -0
  58. package/src/security-config.test.ts +30 -0
  59. package/src/user-config.test.ts +113 -0
  60. package/src/user-config.ts +359 -0
@@ -0,0 +1,149 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { promises as fs } from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { loadConfig } from './loader.js';
6
+
7
+ let tmp: string;
8
+ beforeEach(async () => {
9
+ tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-yaml-cfg-'));
10
+ });
11
+ afterEach(async () => {
12
+ await fs.rm(tmp, { recursive: true, force: true });
13
+ });
14
+
15
+ describe('YAML config loading', () => {
16
+ it('loads a moxxy.config.yaml from cwd', async () => {
17
+ await fs.writeFile(
18
+ path.join(tmp, 'moxxy.config.yaml'),
19
+ `plugins:
20
+ provider:
21
+ default: anthropic
22
+ items:
23
+ anthropic:
24
+ model: claude-sonnet-4-6
25
+ mode:
26
+ default: default
27
+ `,
28
+ );
29
+ const result = await loadConfig({ cwd: tmp, skipUser: true });
30
+ expect(result.config.plugins?.provider?.default).toBe('anthropic');
31
+ expect(result.config.plugins?.provider?.items?.anthropic?.model).toBe('claude-sonnet-4-6');
32
+ expect(result.config.plugins?.mode?.default).toBe('default');
33
+ expect(result.sources[0]?.scope).toBe('project');
34
+ });
35
+
36
+ it('loads .yml extension too', async () => {
37
+ await fs.writeFile(path.join(tmp, 'moxxy.config.yml'), `plugins:\n mode:\n default: research\n`);
38
+ const result = await loadConfig({ cwd: tmp, skipUser: true });
39
+ expect(result.config.plugins?.mode?.default).toBe('research');
40
+ });
41
+
42
+ it('walks upward to find a yaml config', async () => {
43
+ const nested = path.join(tmp, 'a/b/c');
44
+ await fs.mkdir(nested, { recursive: true });
45
+ await fs.writeFile(path.join(tmp, 'moxxy.config.yaml'), `plugins:\n mode:\n default: default\n`);
46
+ const result = await loadConfig({ cwd: nested, skipUser: true });
47
+ expect(result.config.plugins?.mode?.default).toBe('default');
48
+ });
49
+
50
+ it('rejects a yaml config whose schema is invalid', async () => {
51
+ await fs.writeFile(
52
+ path.join(tmp, 'moxxy.config.yaml'),
53
+ `plugins:
54
+ provider:
55
+ default: 42
56
+ `,
57
+ );
58
+ await expect(loadConfig({ cwd: tmp, skipUser: true })).rejects.toThrow(/Invalid moxxy config/);
59
+ });
60
+
61
+ it('rejects an unknown key inside the closed plugins tree', async () => {
62
+ await fs.writeFile(path.join(tmp, 'moxxy.config.yaml'), `plugins:\n provdier:\n default: anthropic\n`);
63
+ await expect(loadConfig({ cwd: tmp, skipUser: true })).rejects.toThrow(/Invalid moxxy config/);
64
+ });
65
+
66
+ it('accepts an empty yaml file', async () => {
67
+ await fs.writeFile(path.join(tmp, 'moxxy.config.yaml'), '');
68
+ const result = await loadConfig({ cwd: tmp, skipUser: true });
69
+ expect(result.config).toEqual({});
70
+ });
71
+
72
+ it('handles complex nested config (packages, channels, embedder)', async () => {
73
+ await fs.writeFile(
74
+ path.join(tmp, 'moxxy.config.yaml'),
75
+ `plugins:
76
+ provider:
77
+ default: anthropic
78
+ items:
79
+ anthropic:
80
+ model: claude-sonnet-4-6
81
+ embedder:
82
+ default: openai
83
+ items:
84
+ openai:
85
+ model: text-embedding-3-small
86
+ packages:
87
+ '@moxxy/plugin-browser':
88
+ enabled: false
89
+ channels:
90
+ http:
91
+ port: 8080
92
+ allowedTools:
93
+ - Read
94
+ - Glob
95
+ `,
96
+ );
97
+ const result = await loadConfig({ cwd: tmp, skipUser: true });
98
+ expect(result.config.plugins?.embedder?.default).toBe('openai');
99
+ expect(result.config.plugins?.packages?.['@moxxy/plugin-browser']?.enabled).toBe(false);
100
+ expect(result.config.channels?.['http']).toEqual({
101
+ port: 8080,
102
+ allowedTools: ['Read', 'Glob'],
103
+ });
104
+ });
105
+
106
+ it('accepts a partial security block missing `enabled` (optional field)', async () => {
107
+ // A hand-written or config_set-built `security:` block with only `strict`
108
+ // must validate; `enabled` defaults to false at the consumer.
109
+ await fs.writeFile(path.join(tmp, 'moxxy.config.yaml'), `security:\n strict: true\n`);
110
+ const result = await loadConfig({ cwd: tmp, skipUser: true });
111
+ expect(result.config.security?.strict).toBe(true);
112
+ expect(result.config.security?.enabled).toBeUndefined();
113
+ });
114
+
115
+ it('preserves security.strict instead of silently stripping it', async () => {
116
+ // Regression: `strict` is consumed by @moxxy/plugin-security
117
+ // (SecurityPluginConfig.strict). If it were absent from securityConfigSchema,
118
+ // zod would strip the unknown key on load and a user who set
119
+ // `security.strict: true` would silently lose the hardening.
120
+ await fs.writeFile(
121
+ path.join(tmp, 'moxxy.config.yaml'),
122
+ `security:\n enabled: true\n strict: true\n`,
123
+ );
124
+ const result = await loadConfig({ cwd: tmp, skipUser: true });
125
+ expect(result.config.security?.strict).toBe(true);
126
+ });
127
+
128
+ it('accepts an embedder slot with item options', async () => {
129
+ await fs.writeFile(
130
+ path.join(tmp, 'moxxy.config.yaml'),
131
+ `plugins:\n embedder:\n items:\n openai:\n model: text-embedding-3-small\n`,
132
+ );
133
+ const result = await loadConfig({ cwd: tmp, skipUser: true });
134
+ expect(result.config.plugins?.embedder?.items?.openai?.model).toBe('text-embedding-3-small');
135
+ expect(result.config.plugins?.embedder?.default).toBeUndefined();
136
+ });
137
+
138
+ it('YAML at project level is overridden by .ts at same level (loader precedence)', async () => {
139
+ // Both exist; first match wins per CONFIG_NAMES order. YAML is listed first
140
+ // so it should take precedence over .ts. This codifies the order.
141
+ await fs.writeFile(path.join(tmp, 'moxxy.config.yaml'), `plugins:\n mode:\n default: default\n`);
142
+ await fs.writeFile(
143
+ path.join(tmp, 'moxxy.config.js'),
144
+ `export default { plugins: { mode: { default: 'research' } } };`,
145
+ );
146
+ const result = await loadConfig({ cwd: tmp, skipUser: true });
147
+ expect(result.config.plugins?.mode?.default).toBe('default');
148
+ });
149
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { mergeConfigs } from './merge.js';
3
+
4
+ describe('mergeConfigs', () => {
5
+ it('returns empty for no inputs', () => {
6
+ expect(mergeConfigs()).toEqual({});
7
+ });
8
+
9
+ it('passes through a single config unchanged', () => {
10
+ const a = { provider: { name: 'anthropic', model: 'sonnet' } };
11
+ expect(mergeConfigs(a)).toEqual(a);
12
+ });
13
+
14
+ it('later wins on scalar fields', () => {
15
+ const a = { provider: { name: 'anthropic', model: 'haiku' } };
16
+ const b = { provider: { name: 'anthropic', model: 'sonnet' } };
17
+ expect(mergeConfigs(a, b).provider?.model).toBe('sonnet');
18
+ });
19
+
20
+ it('merges nested objects key-by-key', () => {
21
+ const a = { plugins: { 'a': { enabled: true } } };
22
+ const b = { plugins: { 'b': { enabled: false } } };
23
+ expect(mergeConfigs(a, b).plugins).toEqual({
24
+ a: { enabled: true },
25
+ b: { enabled: false },
26
+ });
27
+ });
28
+
29
+ it('skips undefined entries', () => {
30
+ expect(mergeConfigs(undefined, { provider: { name: 'x' } }, undefined)).toEqual({
31
+ provider: { name: 'x' },
32
+ });
33
+ });
34
+
35
+ it('concatenates arrays rather than replacing', () => {
36
+ const a = { permissions: { allow: [{ name: 'Read' }] } };
37
+ const b = { permissions: { allow: [{ name: 'Edit' }] } };
38
+ expect(mergeConfigs(a, b).permissions?.allow).toEqual([{ name: 'Read' }, { name: 'Edit' }]);
39
+ });
40
+
41
+ it('merges plugin-specific options deeply', () => {
42
+ const a = { plugins: { p: { options: { a: 1, deep: { x: 1 } } } } };
43
+ const b = { plugins: { p: { options: { b: 2, deep: { y: 2 } } } } };
44
+ expect(mergeConfigs(a, b).plugins?.p?.options).toEqual({
45
+ a: 1,
46
+ b: 2,
47
+ deep: { x: 1, y: 2 },
48
+ });
49
+ });
50
+
51
+ it('ignores a literal __proto__ key without corrupting the merged object', () => {
52
+ // A parsed config (JSON default-export / some YAML) can carry an own
53
+ // enumerable `__proto__`; assigning it would hit the prototype setter,
54
+ // silently drop the data AND replace the result's prototype.
55
+ const malicious = JSON.parse('{"mode":"goal","__proto__":{"polluted":true}}') as Record<
56
+ string,
57
+ unknown
58
+ >;
59
+ const out = mergeConfigs(malicious as never) as Record<string, unknown>;
60
+ expect(out.mode).toBe('goal');
61
+ // No prototype pollution leaked onto Object.prototype.
62
+ expect(({} as Record<string, unknown>).polluted).toBeUndefined();
63
+ // The merged object keeps a plain-object prototype (not corrupted).
64
+ expect(Object.getPrototypeOf(out)).toBe(Object.prototype);
65
+ expect('polluted' in out).toBe(false);
66
+ });
67
+
68
+ it('ignores literal constructor / prototype keys', () => {
69
+ const src = JSON.parse('{"mode":"x","constructor":{"bad":1},"prototype":{"bad":2}}') as Record<
70
+ string,
71
+ unknown
72
+ >;
73
+ const out = mergeConfigs(src as never) as Record<string, unknown>;
74
+ expect(out.mode).toBe('x');
75
+ expect(typeof out.constructor).toBe('function');
76
+ expect('prototype' in out).toBe(false);
77
+ });
78
+ });
package/src/merge.ts ADDED
@@ -0,0 +1,44 @@
1
+ import type { MoxxyConfig } from './schema.js';
2
+
3
+ /**
4
+ * Deep-merge any number of configs in order. Later wins on scalars; arrays are concatenated;
5
+ * objects are merged key-by-key.
6
+ *
7
+ * Precedence (highest → lowest): CLI flags → project config → user config → defaults.
8
+ */
9
+ export function mergeConfigs(...configs: ReadonlyArray<MoxxyConfig | undefined>): MoxxyConfig {
10
+ const out: MoxxyConfig = {};
11
+ for (const cfg of configs) {
12
+ if (!cfg) continue;
13
+ mergeInto(out, cfg);
14
+ }
15
+ return out;
16
+ }
17
+
18
+ // Keys that, when assigned via `target[key] = ...`, hit a prototype setter
19
+ // instead of creating an own data property — silently dropping the data AND
20
+ // replacing the merged object's prototype. A parsed config (JSON default-export
21
+ // or some YAML inputs) can carry an own enumerable `__proto__`/`constructor`/
22
+ // `prototype` key, so skip them defensively. Legitimate configs never use them.
23
+ const FORBIDDEN_MERGE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
24
+
25
+ function mergeInto(target: Record<string, unknown>, source: Record<string, unknown>): void {
26
+ for (const [key, value] of Object.entries(source)) {
27
+ if (value === undefined) continue;
28
+ if (FORBIDDEN_MERGE_KEYS.has(key)) continue;
29
+ const existing = target[key];
30
+ if (Array.isArray(value)) {
31
+ target[key] = Array.isArray(existing) ? [...existing, ...value] : [...value];
32
+ } else if (isPlainObject(value)) {
33
+ const base = isPlainObject(existing) ? { ...existing } : {};
34
+ mergeInto(base, value as Record<string, unknown>);
35
+ target[key] = base;
36
+ } else {
37
+ target[key] = value;
38
+ }
39
+ }
40
+ }
41
+
42
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
43
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
44
+ }
@@ -0,0 +1,18 @@
1
+ import { z } from 'zod';
2
+
3
+ /** Per-package settings in the `plugins.packages` install/enable ledger. */
4
+ export const pluginSettingsSchema = z
5
+ .object({
6
+ /** Plug/unplug the whole package (all its contributions). Default true. */
7
+ enabled: z.boolean().optional(),
8
+ /** Package-specific options passed to the plugin. */
9
+ options: z.record(z.string(), z.unknown()).optional(),
10
+ /**
11
+ * Optional per-contribution exclusions, e.g. `["tool:foo"]`, to suppress a
12
+ * single contribution of an otherwise-enabled package. Honored at
13
+ * registration time. Kept optional so the common case stays one line.
14
+ */
15
+ exclude: z.array(z.string()).optional(),
16
+ })
17
+ .strict();
18
+ export type PluginSettings = z.infer<typeof pluginSettingsSchema>;
@@ -0,0 +1,310 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { promises as fs } from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { asSessionId, asToolCallId, asTurnId, type ToolContext } from '@moxxy/sdk';
6
+ import { buildConfigPlugin, type ConfigApplier, type ConfigApplyResult } from './plugin.js';
7
+
8
+ let tmp: string;
9
+
10
+ const ctx: ToolContext = {
11
+ sessionId: asSessionId('s'),
12
+ turnId: asTurnId('t'),
13
+ callId: asToolCallId('c'),
14
+ cwd: '/tmp',
15
+ signal: new AbortController().signal,
16
+ log: { length: 0, at: () => undefined, slice: () => [], ofType: () => [], byTurn: () => [], toJSON: () => [] },
17
+ logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
18
+ };
19
+
20
+ beforeEach(async () => {
21
+ tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-cfg-plug-'));
22
+ });
23
+ afterEach(async () => {
24
+ await fs.rm(tmp, { recursive: true, force: true });
25
+ });
26
+
27
+ function tool(name: string) {
28
+ const plugin = buildConfigPlugin({ cwd: tmp });
29
+ const t = plugin.tools?.find((x) => x.name === name);
30
+ if (!t) throw new Error(`tool not found: ${name}`);
31
+ return t;
32
+ }
33
+
34
+ describe('buildConfigPlugin tools', () => {
35
+ it('config_path returns null when no project file exists', async () => {
36
+ const out = (await tool('config_path').handler({ scope: 'project' }, ctx)) as {
37
+ scope: string;
38
+ path: string | null;
39
+ };
40
+ expect(out.scope).toBe('project');
41
+ expect(out.path).toBeNull();
42
+ });
43
+
44
+ it('config_path finds an existing moxxy.config.yaml in cwd', async () => {
45
+ await fs.writeFile(path.join(tmp, 'moxxy.config.yaml'), 'mode: default\n');
46
+ const out = (await tool('config_path').handler({ scope: 'project' }, ctx)) as {
47
+ path: string;
48
+ };
49
+ expect(out.path).toContain('moxxy.config.yaml');
50
+ });
51
+
52
+ it('config_path walks upward to a project config in an ancestor dir', async () => {
53
+ // Shared upward-walk (loader.findUpward): a config in `tmp` must be found
54
+ // from a nested subdir, and ONLY the YAML names are matched (the editor
55
+ // can't safely mutate a .ts config, so it must not resolve one).
56
+ await fs.writeFile(path.join(tmp, 'moxxy.config.yaml'), 'mode: default\n');
57
+ const nested = path.join(tmp, 'a', 'b', 'c');
58
+ await fs.mkdir(nested, { recursive: true });
59
+ const plugin = buildConfigPlugin({ cwd: nested });
60
+ const t = plugin.tools?.find((x) => x.name === 'config_path');
61
+ if (!t) throw new Error('config_path not found');
62
+ const out = (await t.handler({ scope: 'project' }, ctx)) as { path: string | null };
63
+ expect(out.path).toBe(path.join(tmp, 'moxxy.config.yaml'));
64
+ });
65
+
66
+ it('config_path does NOT resolve a .ts project config (editor only handles YAML)', async () => {
67
+ // loadConfig honors moxxy.config.ts, but the editor walk deliberately omits
68
+ // it — config_set can only YAML-edit, so resolving a .ts here would let it
69
+ // create a competing .yaml. The divergent name list is by design.
70
+ await fs.writeFile(path.join(tmp, 'moxxy.config.ts'), 'export default {}\n');
71
+ const out = (await tool('config_path').handler({ scope: 'project' }, ctx)) as {
72
+ path: string | null;
73
+ };
74
+ expect(out.path).toBeNull();
75
+ });
76
+
77
+ it('config_show returns the raw text', async () => {
78
+ await fs.writeFile(path.join(tmp, 'moxxy.config.yaml'), 'mode: default\n');
79
+ const out = (await tool('config_show').handler({ scope: 'project' }, ctx)) as {
80
+ text: string;
81
+ };
82
+ expect(out.text).toContain('mode: default');
83
+ });
84
+
85
+ it('config_get reads a value at a dot-path', async () => {
86
+ await fs.writeFile(
87
+ path.join(tmp, 'moxxy.config.yaml'),
88
+ `provider:\n model: sonnet\n config:\n apiKey: k\n`,
89
+ );
90
+ expect(await tool('config_get').handler({ scope: 'project', path: 'provider.model' }, ctx)).toBe('sonnet');
91
+ expect(await tool('config_get').handler({ scope: 'project', path: 'provider.config.apiKey' }, ctx)).toBe('k');
92
+ });
93
+
94
+ it('config_get preserves legitimate falsy values (false / 0 / "") and only nulls a missing key', async () => {
95
+ await fs.writeFile(
96
+ path.join(tmp, 'moxxy.config.yaml'),
97
+ `context:\n caching: false\nmaxIterations: 0\nlabel: ""\n`,
98
+ );
99
+ // false / 0 / "" must round-trip as themselves; `cursor ?? null` used to
100
+ // collapse all three to null so the model couldn't tell "set to false"
101
+ // from "absent" when inspecting config.
102
+ expect(await tool('config_get').handler({ scope: 'project', path: 'context.caching' }, ctx)).toBe(false);
103
+ expect(await tool('config_get').handler({ scope: 'project', path: 'maxIterations' }, ctx)).toBe(0);
104
+ expect(await tool('config_get').handler({ scope: 'project', path: 'label' }, ctx)).toBe('');
105
+ // A genuinely absent key still returns null.
106
+ expect(await tool('config_get').handler({ scope: 'project', path: 'context.missing' }, ctx)).toBeNull();
107
+ });
108
+
109
+ it('config_get does not traverse into the prototype chain or index scalars', async () => {
110
+ await fs.writeFile(
111
+ path.join(tmp, 'moxxy.config.yaml'),
112
+ `provider:\n model: sonnet\n`,
113
+ );
114
+ // Prototype-chain hops must return null, not built-in members.
115
+ expect(
116
+ await tool('config_get').handler(
117
+ { scope: 'project', path: 'constructor.prototype.toString' },
118
+ ctx,
119
+ ),
120
+ ).toBeNull();
121
+ expect(
122
+ await tool('config_get').handler({ scope: 'project', path: '__proto__.polluted' }, ctx),
123
+ ).toBeNull();
124
+ // Descending into a string value (char-by-index) must not leak characters.
125
+ expect(
126
+ await tool('config_get').handler({ scope: 'project', path: 'provider.model.0' }, ctx),
127
+ ).toBeNull();
128
+ });
129
+
130
+ it('config_set writes a value, preserving the rest of the file', async () => {
131
+ await fs.writeFile(
132
+ path.join(tmp, 'moxxy.config.yaml'),
133
+ `mode: default\nprovider:\n name: anthropic\n model: haiku\n`,
134
+ );
135
+ await tool('config_set').handler(
136
+ { scope: 'project', path: 'provider.model', value: '"sonnet"' },
137
+ ctx,
138
+ );
139
+ const text = await fs.readFile(path.join(tmp, 'moxxy.config.yaml'), 'utf8');
140
+ expect(text).toContain('mode: default');
141
+ expect(text).toContain('name: anthropic');
142
+ expect(text).toContain('model: sonnet');
143
+ });
144
+
145
+ it('config_set parses JSON values', async () => {
146
+ await tool('config_set').handler(
147
+ { scope: 'project', path: 'channels.http.allowedTools', value: '["Read","Glob"]' },
148
+ ctx,
149
+ );
150
+ const text = await fs.readFile(path.join(tmp, 'moxxy.config.yaml'), 'utf8');
151
+ expect(text).toMatch(/- Read/);
152
+ expect(text).toMatch(/- Glob/);
153
+ });
154
+
155
+ it('config_set rejects writes that would violate the schema', async () => {
156
+ await expect(
157
+ tool('config_set').handler(
158
+ { scope: 'project', path: 'plugins.provider.default', value: '42' },
159
+ ctx,
160
+ ),
161
+ ).rejects.toThrow(/invalid config/);
162
+ });
163
+
164
+ it('config_init creates a starter yaml when missing', async () => {
165
+ const out = (await tool('config_init').handler({ scope: 'project' }, ctx)) as {
166
+ created: boolean;
167
+ path: string;
168
+ };
169
+ expect(out.created).toBe(true);
170
+ const text = await fs.readFile(out.path, 'utf8');
171
+ expect(text).toContain('plugins:');
172
+ expect(text).toContain('default: anthropic');
173
+ expect(text).toContain('default: default');
174
+ });
175
+
176
+ it('config_init is a no-op when a file already exists', async () => {
177
+ await fs.writeFile(path.join(tmp, 'moxxy.config.yaml'), 'mode: default\n');
178
+ const out = (await tool('config_init').handler({ scope: 'project' }, ctx)) as {
179
+ created: boolean;
180
+ };
181
+ expect(out.created).toBe(false);
182
+ });
183
+
184
+ // invariant 5: two concurrent config_set calls on the same plugin instance
185
+ // each apply only their own edit on top of the same stale doc; without the
186
+ // per-instance mutex the last atomic rename clobbers the other edit.
187
+ it('serializes concurrent config_set on one instance (no lost update)', async () => {
188
+ await fs.writeFile(
189
+ path.join(tmp, 'moxxy.config.yaml'),
190
+ 'mode: default\nprovider:\n name: anthropic\n model: haiku\n',
191
+ );
192
+ const plugin = buildConfigPlugin({ cwd: tmp });
193
+ const setTool = plugin.tools?.find((x) => x.name === 'config_set');
194
+ if (!setTool) throw new Error('config_set not found');
195
+ await Promise.all([
196
+ setTool.handler({ scope: 'project', path: 'provider.model', value: '"sonnet"' }, ctx),
197
+ setTool.handler({ scope: 'project', path: 'mode', value: '"goal"' }, ctx),
198
+ ]);
199
+ const text = await fs.readFile(path.join(tmp, 'moxxy.config.yaml'), 'utf8');
200
+ const yamlMod = await import('yaml');
201
+ const parsed = yamlMod.parse(text) as { mode?: string; provider?: { model?: string } };
202
+ expect(parsed.mode).toBe('goal');
203
+ expect(parsed.provider?.model).toBe('sonnet');
204
+ });
205
+ });
206
+
207
+ describe('buildConfigPlugin runtime applier', () => {
208
+ // Isolate MOXXY_HOME so loadConfig (config_reload/config_validate) never
209
+ // merges the developer's real ~/.moxxy/config.yaml — keeps these tests
210
+ // deterministic regardless of the host machine's user config.
211
+ let prevHome: string | undefined;
212
+ beforeEach(async () => {
213
+ prevHome = process.env.MOXXY_HOME;
214
+ process.env.MOXXY_HOME = path.join(tmp, 'home');
215
+ await fs.mkdir(process.env.MOXXY_HOME, { recursive: true });
216
+ });
217
+ afterEach(() => {
218
+ if (prevHome === undefined) delete process.env.MOXXY_HOME;
219
+ else process.env.MOXXY_HOME = prevHome;
220
+ });
221
+
222
+ function pluginWith(applier?: ConfigApplier) {
223
+ return buildConfigPlugin({ cwd: tmp, applier });
224
+ }
225
+ function toolOf(plugin: ReturnType<typeof buildConfigPlugin>, name: string) {
226
+ const t = plugin.tools?.find((x) => x.name === name);
227
+ if (!t) throw new Error(`tool not found: ${name}`);
228
+ return t;
229
+ }
230
+
231
+ it('config_set surfaces the applier runtime result and passes the validated snapshot', async () => {
232
+ const seen: unknown[] = [];
233
+ const applier: ConfigApplier = async (snapshot) => {
234
+ seen.push(snapshot);
235
+ return { applied: ['mode'], pending: ['provider.name'] };
236
+ };
237
+ const out = (await toolOf(pluginWith(applier), 'config_set').handler(
238
+ { scope: 'project', path: 'plugins.mode.default', value: '"goal"' },
239
+ ctx,
240
+ )) as { runtime: ConfigApplyResult };
241
+ expect(out.runtime).toEqual({ applied: ['mode'], pending: ['provider.name'] });
242
+ expect(seen).toHaveLength(1);
243
+ expect((seen[0] as { plugins?: { mode?: { default?: string } } }).plugins?.mode?.default).toBe(
244
+ 'goal',
245
+ );
246
+ });
247
+
248
+ it('config_set catches an applier throw and reports it as a reload-failed pending entry', async () => {
249
+ const applier: ConfigApplier = async () => {
250
+ throw new Error('boom');
251
+ };
252
+ const out = (await toolOf(pluginWith(applier), 'config_set').handler(
253
+ { scope: 'project', path: 'mode', value: '"goal"' },
254
+ ctx,
255
+ )) as { runtime: ConfigApplyResult };
256
+ expect(out.runtime.applied).toEqual([]);
257
+ expect(out.runtime.pending).toEqual(['reload-failed: boom']);
258
+ // The write still landed even though the live-apply failed.
259
+ const text = await fs.readFile(path.join(tmp, 'moxxy.config.yaml'), 'utf8');
260
+ expect(text).toContain('mode: goal');
261
+ });
262
+
263
+ it('config_set returns an empty runtime result when no applier is wired', async () => {
264
+ const out = (await toolOf(pluginWith(), 'config_set').handler(
265
+ { scope: 'project', path: 'mode', value: '"goal"' },
266
+ ctx,
267
+ )) as { runtime: ConfigApplyResult };
268
+ expect(out.runtime).toEqual({ applied: [], pending: [] });
269
+ });
270
+
271
+ it('config_reload returns the no-applier sentinel when applier omitted', async () => {
272
+ const out = (await toolOf(pluginWith(), 'config_reload').handler({}, ctx)) as ConfigApplyResult;
273
+ expect(out.applied).toEqual([]);
274
+ expect(out.pending).toEqual(['(no runtime applier configured)']);
275
+ });
276
+
277
+ it('config_reload loads fresh config from disk and forwards it to the applier', async () => {
278
+ await fs.writeFile(path.join(tmp, 'moxxy.config.yaml'), 'plugins:\n mode:\n default: goal\n');
279
+ let received: { plugins?: { mode?: { default?: string } } } | undefined;
280
+ const applier: ConfigApplier = async (snapshot) => {
281
+ received = snapshot;
282
+ return { applied: ['mode'], pending: [] };
283
+ };
284
+ const out = (await toolOf(pluginWith(applier), 'config_reload').handler({}, ctx)) as ConfigApplyResult;
285
+ expect(received?.plugins?.mode?.default).toBe('goal');
286
+ expect(out).toEqual({ applied: ['mode'], pending: [] });
287
+ });
288
+
289
+ it('config_validate reports ok for a valid on-disk config', async () => {
290
+ await fs.writeFile(path.join(tmp, 'moxxy.config.yaml'), 'mode: default\n');
291
+ const out = (await toolOf(pluginWith(), 'config_validate').handler({}, ctx)) as {
292
+ ok: boolean;
293
+ error?: string;
294
+ };
295
+ expect(out.ok).toBe(true);
296
+ });
297
+
298
+ it('config_validate reports {ok:false,error} for an invalid on-disk config', async () => {
299
+ await fs.writeFile(
300
+ path.join(tmp, 'moxxy.config.yaml'),
301
+ 'plugins:\n provider:\n default: 42\n',
302
+ );
303
+ const out = (await toolOf(pluginWith(), 'config_validate').handler({}, ctx)) as {
304
+ ok: boolean;
305
+ error?: string;
306
+ };
307
+ expect(out.ok).toBe(false);
308
+ expect(typeof out.error).toBe('string');
309
+ });
310
+ });