@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
package/src/schema.ts ADDED
@@ -0,0 +1,218 @@
1
+ import { z } from 'zod';
2
+ import { pluginSettingsSchema } from './plugin-settings-schema.js';
3
+ import { pluginsTreeSchema } from './plugins-tree-schema.js';
4
+
5
+ export const watcherModeSchema = z.enum(['auto', 'manual', 'off']);
6
+
7
+ export const permissionsConfigSchema = z.object({
8
+ policyPath: z.string().optional(),
9
+ allow: z
10
+ .array(
11
+ z.object({
12
+ name: z.string(),
13
+ inputMatches: z.record(z.string(), z.string()).optional(),
14
+ reason: z.string().optional(),
15
+ }),
16
+ )
17
+ .optional(),
18
+ deny: z
19
+ .array(
20
+ z.object({
21
+ name: z.string(),
22
+ inputMatches: z.record(z.string(), z.string()).optional(),
23
+ reason: z.string().optional(),
24
+ }),
25
+ )
26
+ .optional(),
27
+ });
28
+
29
+ export const securityConfigSchema = z.object({
30
+ /**
31
+ * Master toggle. When omitted or false (the default), `@moxxy/plugin-security`
32
+ * is a no-op even if registered — every tool runs exactly as it does
33
+ * without the plugin. Per-tool `isolation: { ... }` declarations
34
+ * remain as documentation but are not enforced. Optional so a partial
35
+ * `security:` block (e.g. only `isolator`) validates and config_set can
36
+ * write one field at a time; consumers default it to false.
37
+ */
38
+ enabled: z.boolean().optional(),
39
+ // The default isolator now lives in the unified tree at
40
+ // `plugins.isolator.default` (a registry kind like any other); it is no
41
+ // longer a bespoke `security.isolator` key.
42
+ /**
43
+ * Per-tool isolator overrides keyed by tool name. e.g.
44
+ * `{ bash: 'subprocess', memory_save: 'none' }`. Falls back to the
45
+ * default isolator above when a tool isn't listed.
46
+ */
47
+ perTool: z.record(z.string(), z.string()).optional(),
48
+ /**
49
+ * Per-plugin isolator overrides keyed by plugin name. Applies to
50
+ * every tool the plugin contributes unless overridden in `perTool`.
51
+ */
52
+ perPlugin: z.record(z.string(), z.string()).optional(),
53
+ /**
54
+ * When true, tools without a declared `isolation` field are denied
55
+ * outright (instead of falling through to the default isolator).
56
+ * Useful for hardening once every in-use tool has been audited.
57
+ */
58
+ requireDeclaration: z.boolean().optional(),
59
+ /**
60
+ * The requireDeclaration ratchet for THIRD-PARTY plugins only — packages
61
+ * outside the trusted `@moxxy/` scope. Applies when a tool has no
62
+ * `isolation` declaration and the global `requireDeclaration` above did
63
+ * not already deny it:
64
+ * - 'off' — third-party tools are treated like first-party ones.
65
+ * - 'warn' — grace mode (the default while `security.enabled`): the
66
+ * call runs, but a structured warning is logged once per
67
+ * tool per process.
68
+ * - 'enforce' — the call is denied with a reason naming this flag.
69
+ * Tools with no resolvable owning plugin (e.g. runtime-attached MCP
70
+ * tools) are exempt — the npm-scope test is meaningless for them.
71
+ * Consumed by `@moxxy/plugin-security`
72
+ * (`SecurityPluginConfig.thirdPartyRequireDeclaration`).
73
+ */
74
+ thirdPartyRequireDeclaration: z.enum(['off', 'warn', 'enforce']).optional(),
75
+ /**
76
+ * Tighten the in-process input cap-check from best-effort to fail-closed.
77
+ * By default the fs/net checks only inspect string values under a recognized
78
+ * key name (`file`, `path`, `url`, …), so a path/URL carried by an
79
+ * unrecognized field (`config`, `manifest`, `webhook`) is not checked. With
80
+ * `strict: true`, any string value that is unambiguously an absolute path or a
81
+ * bare http(s) URL is treated as in-scope-required regardless of key name, so
82
+ * an unrecognized carrier fails closed. Consumed by `@moxxy/plugin-security`
83
+ * (`SecurityPluginConfig.strict`); without this field the loader's schema would
84
+ * silently strip a user's `security.strict: true` and the hardening would be
85
+ * lost. Defaults to false at the consumer.
86
+ */
87
+ strict: z.boolean().optional(),
88
+ });
89
+
90
+ export const embeddingsConfigSchema = z.object({
91
+ /**
92
+ * 'tfidf' (default, zero deps) | 'openai' (text-embedding-3-*)
93
+ * | 'transformers' (local, @huggingface/transformers) | 'none' (disable).
94
+ * Optional so a partial `embeddings:` block (e.g. only `model`) validates and
95
+ * config_set can write one field at a time; consumers default it to 'tfidf'.
96
+ */
97
+ provider: z.enum(['tfidf', 'openai', 'transformers', 'none']).optional(),
98
+ model: z.string().optional(),
99
+ dimensions: z.number().int().positive().optional(),
100
+ apiKey: z.string().optional(),
101
+ batchSize: z.number().int().positive().optional(),
102
+ cacheDir: z.string().optional(),
103
+ /** Persist computed embeddings to ~/.moxxy/memory/.embeddings.json. */
104
+ persistIndex: z.boolean().optional(),
105
+ });
106
+
107
+ /**
108
+ * Turn-boundary elision settings (context-on-demand). Off-by-floor safety:
109
+ * `keepRecentTurns` never drops below 2 and elision is skipped while the
110
+ * context is under `minContextRatioToElide` full.
111
+ */
112
+ export const elisionConfigSchema = z.object({
113
+ enabled: z.boolean().optional(),
114
+ keepRecentTurns: z.number().int().min(2).optional(),
115
+ minContextRatioToElide: z.number().min(0).max(1).optional(),
116
+ /** Also collapse old user/assistant text turns (not just bulky tool results). */
117
+ elideConversational: z.boolean().optional(),
118
+ /** Auto-disable conversational elision after this many `recall({seq})` calls. */
119
+ conversationalRecallThreshold: z.number().int().positive().optional(),
120
+ maxRecallBytes: z.number().int().positive().optional(),
121
+ neverElideTools: z.array(z.string()).optional(),
122
+ });
123
+
124
+ /** Context-window / token-efficiency settings. */
125
+ export const contextConfigSchema = z.object({
126
+ /** Master switch for prompt caching. Default true (lossless). */
127
+ caching: z.boolean().optional(),
128
+ // The active CacheStrategy now lives at `plugins.cacheStrategy.default`.
129
+ elision: elisionConfigSchema.optional(),
130
+ /** Lazy tool loading: send only core + loaded tool schemas, index the rest. Default false. */
131
+ lazyTools: z.boolean().optional(),
132
+ /**
133
+ * Reasoning/thinking preview. `true` enables it at the model's default depth;
134
+ * `{ effort }` sets the depth. Honored only by providers/models that support
135
+ * reasoning (Anthropic adaptive thinking, OpenAI/Codex reasoning). Default off.
136
+ */
137
+ reasoning: z
138
+ .union([z.boolean(), z.object({ effort: z.enum(['low', 'medium', 'high']).optional() })])
139
+ .optional(),
140
+ /**
141
+ * Stuck-loop guard tuning. The guard bails a turn early when the model keeps
142
+ * making the same tool call; `maxIterations` is the hard backstop. Raise the
143
+ * thresholds if legitimately-repeated work is being cut short, or set
144
+ * `enabled: false` to disable the guard and rely on `maxIterations` alone.
145
+ */
146
+ loopGuard: z
147
+ .object({
148
+ enabled: z.boolean().optional(),
149
+ windowSize: z.number().int().positive().optional(),
150
+ repeatThreshold: z.number().int().positive().optional(),
151
+ nearWindowSize: z.number().int().positive().optional(),
152
+ nearThreshold: z.number().int().positive().optional(),
153
+ })
154
+ .strict()
155
+ .optional(),
156
+ });
157
+
158
+ export const moxxyConfigSchema = z.object({
159
+ /**
160
+ * The unified plugins manifest — the single source of truth for what's
161
+ * installed/enabled (`plugins.packages`) and the active default per category
162
+ * (`plugins.<category>.default`). Replaces the legacy flat
163
+ * provider/mode/compactor/workflowExecutor keys, the old `plugins:` map, and
164
+ * `preferences.json`.
165
+ */
166
+ plugins: pluginsTreeSchema.optional(),
167
+ context: contextConfigSchema.optional(),
168
+ systemPrompt: z.string().optional(),
169
+ maxIterations: z.number().int().positive().optional(),
170
+ hookTimeoutMs: z.number().int().positive().optional(),
171
+ watcher: watcherModeSchema.optional(),
172
+ skills: z
173
+ .object({
174
+ projectDir: z.string().optional(),
175
+ userDir: z.string().optional(),
176
+ extraDirs: z.array(z.string()).optional(),
177
+ })
178
+ .optional(),
179
+ security: securityConfigSchema.optional(),
180
+ channels: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
181
+ permissions: permissionsConfigSchema.optional(),
182
+ env: z.record(z.string(), z.string()).optional(),
183
+ /** TUI presentation preferences (read at TUI boot; edited via /settings). */
184
+ tui: z
185
+ .object({
186
+ /** `mono` drops color like NO_COLOR; `default` is the standard palette. */
187
+ theme: z.enum(['default', 'mono']).optional(),
188
+ /** Hide the footer key-hint row when false. */
189
+ hints: z.boolean().optional(),
190
+ /**
191
+ * Ctrl+<letter> overrides for the input-editor command hotkeys. Values
192
+ * are single letters; unknown/conflicting letters are ignored at boot.
193
+ */
194
+ keys: z
195
+ .object({
196
+ forceSend: z.string().length(1).optional(),
197
+ dropQueued: z.string().length(1).optional(),
198
+ toggleTools: z.string().length(1).optional(),
199
+ })
200
+ .strict()
201
+ .optional(),
202
+ })
203
+ .strict()
204
+ .optional(),
205
+ });
206
+
207
+ export type MoxxyConfig = z.infer<typeof moxxyConfigSchema>;
208
+ export type ContextConfig = z.infer<typeof contextConfigSchema>;
209
+ export type ElisionConfig = z.infer<typeof elisionConfigSchema>;
210
+ export type WatcherMode = z.infer<typeof watcherModeSchema>;
211
+ export type PermissionsConfig = z.infer<typeof permissionsConfigSchema>;
212
+ export type EmbeddingsConfig = z.infer<typeof embeddingsConfigSchema>;
213
+ export type SecurityConfig = z.infer<typeof securityConfigSchema>;
214
+
215
+ // `pluginSettingsSchema` is defined in ./plugin-settings-schema and re-exported
216
+ // here for back-references; `PluginSettings` lives there too.
217
+ export { pluginSettingsSchema };
218
+ export type { PluginSettings } from './plugin-settings-schema.js';
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { moxxyConfigSchema, securityConfigSchema } from './schema.js';
3
+
4
+ describe('security.thirdPartyRequireDeclaration schema round-trip', () => {
5
+ it.each(['off', 'warn', 'enforce'] as const)('accepts and preserves %s', (mode) => {
6
+ const parsed = moxxyConfigSchema.parse({
7
+ security: { enabled: true, thirdPartyRequireDeclaration: mode },
8
+ });
9
+ expect(parsed.security?.thirdPartyRequireDeclaration).toBe(mode);
10
+ });
11
+
12
+ it('stays absent when unset (consumer applies the warn default)', () => {
13
+ const parsed = moxxyConfigSchema.parse({ security: { enabled: true } });
14
+ expect(parsed.security?.thirdPartyRequireDeclaration).toBeUndefined();
15
+ });
16
+
17
+ it('rejects values outside the closed enum', () => {
18
+ expect(() =>
19
+ securityConfigSchema.parse({ thirdPartyRequireDeclaration: 'deny' }),
20
+ ).toThrow();
21
+ expect(() =>
22
+ securityConfigSchema.parse({ thirdPartyRequireDeclaration: true }),
23
+ ).toThrow();
24
+ });
25
+
26
+ it('validates as a partial block (the field alone parses)', () => {
27
+ const parsed = securityConfigSchema.parse({ thirdPartyRequireDeclaration: 'enforce' });
28
+ expect(parsed).toEqual({ thirdPartyRequireDeclaration: 'enforce' });
29
+ });
30
+ });
@@ -0,0 +1,113 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { parse as parseYaml } from 'yaml';
6
+ import { applyInitConfig, setPluginEnabled } from './user-config.js';
7
+ import { moxxyConfigSchema } from './schema.js';
8
+
9
+ let tmp: string;
10
+ let configPath: string;
11
+
12
+ beforeEach(async () => {
13
+ tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-user-config-'));
14
+ configPath = path.join(tmp, 'config.yaml');
15
+ });
16
+
17
+ afterEach(async () => {
18
+ await fs.rm(tmp, { recursive: true, force: true });
19
+ });
20
+
21
+ async function readParsed(): Promise<Record<string, unknown>> {
22
+ const raw = await fs.readFile(configPath, 'utf8');
23
+ return parseYaml(raw) as Record<string, unknown>;
24
+ }
25
+
26
+ describe('applyInitConfig', () => {
27
+ it('writes the unified plugins tree the clean-slate schema reads', async () => {
28
+ const written = await applyInitConfig(
29
+ { provider: 'anthropic', model: 'claude-sonnet-4-6', mode: 'goal', embedder: 'openai' },
30
+ { configPath },
31
+ );
32
+ expect(written).toBe(configPath);
33
+
34
+ const parsed = await readParsed();
35
+ expect(parsed).toMatchObject({
36
+ plugins: {
37
+ provider: { default: 'anthropic', items: { anthropic: { model: 'claude-sonnet-4-6' } } },
38
+ mode: { default: 'goal' },
39
+ embedder: { default: 'openai' },
40
+ },
41
+ });
42
+ // The result must validate against the real config schema.
43
+ expect(moxxyConfigSchema.safeParse(parsed).success).toBe(true);
44
+ });
45
+
46
+ it('MERGES into the existing package ledger instead of clobbering it', async () => {
47
+ // Simulate ensureProvider/installPlugins enabling packages BEFORE the wizard
48
+ // persists the provider default + model (the exact init ordering).
49
+ await setPluginEnabled('@moxxy/plugin-provider-anthropic', true, { configPath });
50
+ await setPluginEnabled('@moxxy/plugin-telegram', true, { configPath });
51
+
52
+ await applyInitConfig(
53
+ { provider: 'anthropic', model: 'claude-sonnet-4-6', mode: 'default', embedder: 'tfidf' },
54
+ { configPath },
55
+ );
56
+
57
+ const parsed = (await readParsed()) as {
58
+ plugins: {
59
+ packages: Record<string, { enabled: boolean }>;
60
+ provider: { default: string };
61
+ };
62
+ };
63
+ // Ledger survived the merge.
64
+ expect(parsed.plugins.packages['@moxxy/plugin-provider-anthropic']).toEqual({ enabled: true });
65
+ expect(parsed.plugins.packages['@moxxy/plugin-telegram']).toEqual({ enabled: true });
66
+ // And the provider default landed alongside it.
67
+ expect(parsed.plugins.provider.default).toBe('anthropic');
68
+ });
69
+
70
+ it('omits the tfidf floor, a null model, and the security block by default', async () => {
71
+ await applyInitConfig(
72
+ { provider: 'anthropic', model: null, mode: 'default', embedder: 'tfidf' },
73
+ { configPath },
74
+ );
75
+ const parsed = (await readParsed()) as { plugins: Record<string, unknown>; security?: unknown };
76
+ expect(parsed.plugins.embedder).toBeUndefined();
77
+ expect(parsed.plugins.provider).toEqual({ default: 'anthropic' });
78
+ expect(parsed.security).toBeUndefined();
79
+ });
80
+
81
+ it('writes security.enabled only when opted in', async () => {
82
+ await applyInitConfig(
83
+ { provider: 'anthropic', mode: 'default', embedder: 'tfidf', security: { enabled: true } },
84
+ { configPath },
85
+ );
86
+ const parsed = (await readParsed()) as { security: { enabled: boolean } };
87
+ expect(parsed.security.enabled).toBe(true);
88
+ });
89
+
90
+ it('records fallbacks (excluding the primary) and never references the vault key', async () => {
91
+ await applyInitConfig(
92
+ { provider: 'anthropic', mode: 'default', embedder: 'tfidf', fallbacks: ['anthropic', 'openai'] },
93
+ { configPath },
94
+ );
95
+ const raw = await fs.readFile(configPath, 'utf8');
96
+ const parsed = parseYaml(raw) as { plugins: { provider: { fallbacks: string[] } } };
97
+ expect(parsed.plugins.provider.fallbacks).toEqual(['openai']);
98
+ // Like `moxxy provision`, the key lives in the vault under its canonical
99
+ // name — the config must not carry a ${vault:...} apiKey ref.
100
+ expect(raw).not.toContain('apiKey');
101
+ expect(raw).not.toContain('${vault:');
102
+ });
103
+
104
+ it('preserves user comments across the merge (comment-preserving round-trip)', async () => {
105
+ await fs.writeFile(configPath, '# my hand-written config\nplugins:\n packages: {}\n');
106
+ await applyInitConfig(
107
+ { provider: 'anthropic', mode: 'default', embedder: 'tfidf' },
108
+ { configPath },
109
+ );
110
+ const raw = await fs.readFile(configPath, 'utf8');
111
+ expect(raw).toContain('# my hand-written config');
112
+ });
113
+ });