@animalabs/connectome-host 0.7.3 → 0.8.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 (97) hide show
  1. package/.env.example +12 -5
  2. package/.github/PULL_REQUEST_TEMPLATE.md +3 -2
  3. package/.github/workflows/changelog.yml +9 -4
  4. package/.github/workflows/ci.yml +5 -3
  5. package/.github/workflows/publish.yml +12 -6
  6. package/CHANGELOG.md +401 -10
  7. package/CONTRIBUTING.md +47 -19
  8. package/HEADLESS-FLEET-PLAN.md +22 -0
  9. package/README.md +39 -1
  10. package/bun.lock +27 -31
  11. package/changelog.d/README.md +28 -0
  12. package/docs/AGENT-ONBOARDING.md +1 -1
  13. package/docs/debug-context-api.md +2 -2
  14. package/docs/retrieval-traces.md +173 -0
  15. package/docs/webui-deployment.md +2 -1
  16. package/package.json +6 -6
  17. package/recipes/SETUP.md +11 -5
  18. package/recipes/TRIUMVIRATE-SETUP.md +68 -14
  19. package/recipes/knowledge-miner.json +0 -30
  20. package/recipes/mock-test.json +19 -0
  21. package/recipes/triumvirate.json +6 -1
  22. package/scripts/audit-module-optins.ts +288 -0
  23. package/scripts/release-changelog.ts +210 -21
  24. package/src/cache-keepalive-log.ts +41 -0
  25. package/src/commands.ts +96 -0
  26. package/src/framework-strategy.ts +50 -4
  27. package/src/gate-telemetry.ts +106 -0
  28. package/src/headless.ts +24 -0
  29. package/src/index.ts +179 -64
  30. package/src/mcpl-config.ts +99 -1
  31. package/src/modules/fleet-module.ts +60 -1
  32. package/src/modules/fleet-types.ts +30 -1
  33. package/src/modules/identity-module.ts +310 -2
  34. package/src/modules/instructions-module.ts +265 -0
  35. package/src/modules/mcpl-admin-module.ts +89 -13
  36. package/src/modules/retrieval-module.ts +249 -51
  37. package/src/modules/retrieval-trace-page.ts +254 -0
  38. package/src/modules/retrieval-trace.ts +904 -0
  39. package/src/modules/subagent-module.ts +18 -0
  40. package/src/modules/tts-relay-module.ts +33 -18
  41. package/src/modules/web-ui-module.ts +445 -894
  42. package/src/recipe.ts +787 -29
  43. package/src/retrieval-config.ts +39 -0
  44. package/src/strategies/frontdesk-strategy.ts +34 -125
  45. package/src/tui.ts +325 -54
  46. package/src/web/panel-data.ts +1206 -0
  47. package/src/web/protocol.ts +75 -10
  48. package/src/workspace-mounts.ts +73 -0
  49. package/test/audit-module-optins.test.ts +174 -0
  50. package/test/cache-keepalive-log.test.ts +83 -0
  51. package/test/conversations-recipe.test.ts +142 -0
  52. package/test/fleet-panel-request.test.ts +90 -0
  53. package/test/framework-fkm-composition.test.ts +35 -3
  54. package/test/framework-strategy-defaults.test.ts +41 -0
  55. package/test/frontdesk-strategy.test.ts +25 -37
  56. package/test/gate-telemetry-adapter.test.ts +84 -0
  57. package/test/gate-telemetry.test.ts +91 -0
  58. package/test/headless-panel-request.test.ts +201 -0
  59. package/test/identity-and-surfaces.test.ts +212 -1
  60. package/test/instructions-module.test.ts +258 -0
  61. package/test/mcpl-admin-module.test.ts +64 -0
  62. package/test/mcpl-agent-overlay.test.ts +51 -3
  63. package/test/mcpl-child-env.test.ts +64 -0
  64. package/test/mock-headless-child.ts +14 -0
  65. package/test/nudge-command.test.ts +47 -0
  66. package/test/recipe-cache-keepalive.test.ts +59 -0
  67. package/test/recipe-compression-fallback.test.ts +19 -0
  68. package/test/recipe-hybrid-prose-routing.test.ts +12 -0
  69. package/test/recipe-instructions.test.ts +176 -0
  70. package/test/recipe-kv-unified.test.ts +87 -0
  71. package/test/recipe-mcp-source.test.ts +54 -0
  72. package/test/recipe-openai-compatible.test.ts +54 -0
  73. package/test/recipe-path-resolution.test.ts +19 -8
  74. package/test/recipe-provider.test.ts +14 -0
  75. package/test/recipe-save-unresolved.test.ts +244 -0
  76. package/test/recipe-source-only.test.ts +38 -0
  77. package/test/release-changelog.test.ts +202 -0
  78. package/test/retrieval-auth-loopback.test.ts +49 -0
  79. package/test/retrieval-config.test.ts +74 -0
  80. package/test/retrieval-module.test.ts +821 -0
  81. package/test/subagent-prose-routing.test.ts +109 -0
  82. package/test/tui-format.test.ts +106 -0
  83. package/test/web-ui-context-coverage.test.ts +1 -1
  84. package/test/web-ui-module.test.ts +189 -3
  85. package/test/web-ui-observers.test.ts +8 -5
  86. package/test/web-ui-protocol.test.ts +0 -0
  87. package/test/workspace-mounts.test.ts +68 -0
  88. package/web/src/App.tsx +160 -44
  89. package/web/src/Context.tsx +35 -8
  90. package/web/src/ContextDocument.tsx +20 -5
  91. package/web/src/Files.tsx +2 -8
  92. package/web/src/Health.tsx +61 -1
  93. package/web/src/Lessons.tsx +2 -38
  94. package/web/src/Mcpl.tsx +80 -14
  95. package/web/src/Pins.tsx +5 -0
  96. package/web/src/Settings.tsx +5 -0
  97. package/web/vite.config.ts +8 -2
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Tests for unresolved recipe persistence: the `.recipe.json` snapshot in
3
+ * $DATA_DIR must never contain substituted secrets.
4
+ *
5
+ * Motivating finding (external recipe review against a production VM):
6
+ * loadRecipe substituted every `${VAR}` — including access tokens — into the
7
+ * recipe, and resolveRecipe then serialised the RESOLVED recipe to
8
+ * $DATA_DIR/.recipe.json, a directory deployments bind-mount and back up.
9
+ *
10
+ * Contract under test:
11
+ * - loadRecipeDetailed returns the resolved recipe for the runtime AND a
12
+ * `persistable` pre-substitution form; saveRecipe writes the latter, so
13
+ * the on-disk snapshot keeps `${VAR}` literals, never the secret values.
14
+ * - loadSavedRecipe re-runs substitution against the CURRENT environment
15
+ * (secret rotation takes effect on restart), hard-failing when a
16
+ * required var has gone missing.
17
+ * - Legacy snapshots (saved fully resolved by older versions, no marker)
18
+ * still load verbatim — including ones with a literal `${...}` in prose.
19
+ * - The snapshot file is chmod'd 0600 even when overwriting a legacy file.
20
+ * - A URL systemPrompt is persisted as the URL and re-fetched on resume.
21
+ */
22
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
23
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, statSync, chmodSync } from 'node:fs';
24
+ import { tmpdir } from 'node:os';
25
+ import { join, resolve } from 'node:path';
26
+ import {
27
+ loadRecipe,
28
+ loadRecipeDetailed,
29
+ saveRecipe,
30
+ loadSavedRecipe,
31
+ SAVED_RECIPE_UNRESOLVED_KEY,
32
+ } from '../src/recipe.js';
33
+
34
+ const SECRET = 'tok-glpat-supersecret-12345';
35
+
36
+ describe('unresolved recipe persistence', () => {
37
+ const originalEnv = process.env;
38
+ const originalFetch = globalThis.fetch;
39
+ let tmpDir: string;
40
+ let dataDir: string;
41
+ let recipePath: string;
42
+
43
+ beforeEach(() => {
44
+ process.env = { ...originalEnv };
45
+ tmpDir = mkdtempSync(join(tmpdir(), 'conhost-save-unresolved-'));
46
+ dataDir = join(tmpDir, 'data');
47
+ recipePath = join(tmpDir, 'recipe.json');
48
+ });
49
+
50
+ afterEach(() => {
51
+ process.env = originalEnv;
52
+ globalThis.fetch = originalFetch;
53
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* noop */ }
54
+ });
55
+
56
+ function writeSecretRecipe(): void {
57
+ writeFileSync(recipePath, JSON.stringify({
58
+ name: 'Secret Test',
59
+ agent: { systemPrompt: 'be helpful' },
60
+ mcpServers: {
61
+ gitlab: {
62
+ command: 'node',
63
+ env: { GITLAB_PERSONAL_ACCESS_TOKEN: '${CONHOST_TEST_SECRET}' },
64
+ },
65
+ },
66
+ }), 'utf-8');
67
+ }
68
+
69
+ test('resolved recipe carries the secret in memory; persistable keeps the ${VAR} literal', async () => {
70
+ process.env.CONHOST_TEST_SECRET = SECRET;
71
+ writeSecretRecipe();
72
+
73
+ const { recipe, persistable } = await loadRecipeDetailed(recipePath);
74
+ expect((recipe.mcpServers as any).gitlab.env.GITLAB_PERSONAL_ACCESS_TOKEN).toBe(SECRET);
75
+ expect((persistable.mcpServers as any).gitlab.env.GITLAB_PERSONAL_ACCESS_TOKEN)
76
+ .toBe('${CONHOST_TEST_SECRET}');
77
+ });
78
+
79
+ test('the saved .recipe.json never contains the substituted secret', async () => {
80
+ process.env.CONHOST_TEST_SECRET = SECRET;
81
+ writeSecretRecipe();
82
+
83
+ const { persistable } = await loadRecipeDetailed(recipePath);
84
+ saveRecipe(dataDir, persistable);
85
+
86
+ const onDisk = readFileSync(join(dataDir, '.recipe.json'), 'utf-8');
87
+ expect(onDisk).not.toContain(SECRET);
88
+ expect(onDisk).toContain('${CONHOST_TEST_SECRET}');
89
+ expect(JSON.parse(onDisk)[SAVED_RECIPE_UNRESOLVED_KEY]).toBe(true);
90
+ });
91
+
92
+ test('loadSavedRecipe re-resolves against the CURRENT environment (secret rotation)', async () => {
93
+ process.env.CONHOST_TEST_SECRET = SECRET;
94
+ writeSecretRecipe();
95
+ const { persistable } = await loadRecipeDetailed(recipePath);
96
+ saveRecipe(dataDir, persistable);
97
+
98
+ process.env.CONHOST_TEST_SECRET = 'tok-rotated-67890';
99
+ const resumed = await loadSavedRecipe(dataDir);
100
+ expect(resumed).not.toBeNull();
101
+ expect((resumed!.mcpServers as any).gitlab.env.GITLAB_PERSONAL_ACCESS_TOKEN)
102
+ .toBe('tok-rotated-67890');
103
+ });
104
+
105
+ test('loadSavedRecipe throws (not null) when a required var disappeared from the environment', async () => {
106
+ process.env.CONHOST_TEST_SECRET = SECRET;
107
+ writeSecretRecipe();
108
+ const { persistable } = await loadRecipeDetailed(recipePath);
109
+ saveRecipe(dataDir, persistable);
110
+
111
+ delete process.env.CONHOST_TEST_SECRET;
112
+ await expect(loadSavedRecipe(dataDir)).rejects.toThrow(/CONHOST_TEST_SECRET/);
113
+ });
114
+
115
+ test('saveRecipe writes the snapshot with mode 0600, and chmods a pre-existing looser file', async () => {
116
+ process.env.CONHOST_TEST_SECRET = SECRET;
117
+ writeSecretRecipe();
118
+ const { persistable } = await loadRecipeDetailed(recipePath);
119
+
120
+ saveRecipe(dataDir, persistable);
121
+ const path = join(dataDir, '.recipe.json');
122
+ expect(statSync(path).mode & 0o777).toBe(0o600);
123
+
124
+ // Legacy deployments have a world-readable resolved snapshot; an
125
+ // overwrite must tighten it even though writeFileSync's mode only
126
+ // applies at creation.
127
+ chmodSync(path, 0o644);
128
+ expect(statSync(path).mode & 0o777).toBe(0o644);
129
+ saveRecipe(dataDir, persistable);
130
+ expect(statSync(path).mode & 0o777).toBe(0o600);
131
+ });
132
+
133
+ test('round-trip with no ${} patterns is a faithful no-op resolution', async () => {
134
+ writeFileSync(recipePath, JSON.stringify({
135
+ name: 'Plain',
136
+ description: 'no env refs',
137
+ agent: { systemPrompt: 'hello', model: 'claude-opus-4-6' },
138
+ }), 'utf-8');
139
+ const { recipe, persistable } = await loadRecipeDetailed(recipePath);
140
+ saveRecipe(dataDir, persistable);
141
+ const resumed = await loadSavedRecipe(dataDir);
142
+ expect(resumed).not.toBeNull();
143
+ expect(resumed!.name).toBe('Plain');
144
+ expect(resumed!.agent.systemPrompt).toBe(recipe.agent.systemPrompt);
145
+ expect(resumed!.agent.model).toBe('claude-opus-4-6');
146
+ });
147
+
148
+ test('relative fleet child recipe paths are persisted absolute (resume has no source base)', async () => {
149
+ writeFileSync(recipePath, JSON.stringify({
150
+ name: 'Fleet Parent',
151
+ agent: { systemPrompt: 'parent' },
152
+ modules: {
153
+ fleet: { children: [{ name: 'child-a', recipe: 'child.json' }] },
154
+ },
155
+ }), 'utf-8');
156
+
157
+ const { recipe, persistable } = await loadRecipeDetailed(recipePath);
158
+ const expected = resolve(tmpDir, 'child.json');
159
+ expect((recipe.modules!.fleet as any).children[0].recipe).toBe(expected);
160
+ expect(((persistable.modules as any).fleet.children[0]).recipe).toBe(expected);
161
+
162
+ saveRecipe(dataDir, persistable);
163
+ const resumed = await loadSavedRecipe(dataDir);
164
+ expect((resumed!.modules!.fleet as any).children[0].recipe).toBe(expected);
165
+ });
166
+
167
+ describe('URL systemPrompt', () => {
168
+ test('persisted as the URL and re-fetched on resume, picking up prompt updates', async () => {
169
+ let fetchCount = 0;
170
+ globalThis.fetch = (async (input: any) => {
171
+ expect(String(input)).toBe('https://prompts.example/agent.txt');
172
+ fetchCount++;
173
+ return new Response(fetchCount === 1 ? 'FETCHED PROMPT v1' : 'FETCHED PROMPT v2');
174
+ }) as typeof fetch;
175
+
176
+ writeFileSync(recipePath, JSON.stringify({
177
+ name: 'URL Prompt',
178
+ agent: { systemPrompt: 'https://prompts.example/agent.txt' },
179
+ }), 'utf-8');
180
+
181
+ const { recipe, persistable } = await loadRecipeDetailed(recipePath);
182
+ expect(recipe.agent.systemPrompt).toBe('FETCHED PROMPT v1');
183
+ expect((persistable.agent as any).systemPrompt).toBe('https://prompts.example/agent.txt');
184
+
185
+ saveRecipe(dataDir, persistable);
186
+ const onDisk = readFileSync(join(dataDir, '.recipe.json'), 'utf-8');
187
+ expect(onDisk).not.toContain('FETCHED PROMPT');
188
+
189
+ const resumed = await loadSavedRecipe(dataDir);
190
+ expect(resumed!.agent.systemPrompt).toBe('FETCHED PROMPT v2');
191
+ expect(fetchCount).toBe(2);
192
+ });
193
+ });
194
+
195
+ describe('legacy resolved snapshots (no marker)', () => {
196
+ test('load verbatim with no substitution', async () => {
197
+ // Simulate an older host's save: fully resolved, no marker.
198
+ const legacy = {
199
+ name: 'Legacy',
200
+ agent: { systemPrompt: 'resolved prompt' },
201
+ mcpServers: { gitlab: { command: 'node', env: { TOKEN: SECRET } } },
202
+ };
203
+ mkdirSync(dataDir, { recursive: true });
204
+ writeFileSync(join(dataDir, '.recipe.json'), JSON.stringify(legacy, null, 2) + '\n', 'utf-8');
205
+
206
+ const resumed = await loadSavedRecipe(dataDir);
207
+ expect(resumed).not.toBeNull();
208
+ expect((resumed!.mcpServers as any).gitlab.env.TOKEN).toBe(SECRET);
209
+ });
210
+
211
+ test('a surviving literal ${...} in prose does not hard-fail the load', async () => {
212
+ delete process.env.DEFINITELY_NOT_SET_ANYWHERE;
213
+ const legacy = {
214
+ name: 'Legacy Prose',
215
+ agent: {
216
+ systemPrompt: 'To configure, set ${DEFINITELY_NOT_SET_ANYWHERE} in your .env file.',
217
+ },
218
+ };
219
+ mkdirSync(dataDir, { recursive: true });
220
+ writeFileSync(join(dataDir, '.recipe.json'), JSON.stringify(legacy) + '\n', 'utf-8');
221
+
222
+ const resumed = await loadSavedRecipe(dataDir);
223
+ expect(resumed).not.toBeNull();
224
+ expect(resumed!.agent.systemPrompt).toContain('${DEFINITELY_NOT_SET_ANYWHERE}');
225
+ });
226
+
227
+ test('corrupt JSON still returns null rather than throwing', async () => {
228
+ mkdirSync(dataDir, { recursive: true });
229
+ writeFileSync(join(dataDir, '.recipe.json'), '{not json', 'utf-8');
230
+ expect(await loadSavedRecipe(dataDir)).toBeNull();
231
+ });
232
+
233
+ test('missing snapshot returns null', async () => {
234
+ expect(await loadSavedRecipe(dataDir)).toBeNull();
235
+ });
236
+ });
237
+
238
+ test('loadRecipe wrapper still returns the resolved recipe', async () => {
239
+ process.env.CONHOST_TEST_SECRET = SECRET;
240
+ writeSecretRecipe();
241
+ const recipe = await loadRecipe(recipePath);
242
+ expect((recipe.mcpServers as any).gitlab.env.GITLAB_PERSONAL_ACCESS_TOKEN).toBe(SECRET);
243
+ });
244
+ });
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { validateRecipe } from '../src/recipe.js';
3
+ import { buildFrameworkStrategy } from '../src/framework-strategy.js';
4
+
5
+ function recipe(strategy: Record<string, unknown>) {
6
+ return {
7
+ name: 'source-only-test',
8
+ agent: { systemPrompt: 'sys', strategy: { type: 'autobiographical', ...strategy } },
9
+ };
10
+ }
11
+
12
+ describe('compressionSourceOnly recipe flag', () => {
13
+ test('preserves the flag through validation', () => {
14
+ expect(validateRecipe(recipe({ compressionSourceOnly: true }))
15
+ .agent.strategy?.compressionSourceOnly).toBe(true);
16
+ expect(validateRecipe(recipe({ compressionSourceOnly: false }))
17
+ .agent.strategy?.compressionSourceOnly).toBe(false);
18
+ // absent stays undefined — every other resident is unaffected
19
+ expect(validateRecipe(recipe({})).agent.strategy?.compressionSourceOnly).toBeUndefined();
20
+ });
21
+
22
+ test('rejects a non-boolean value', () => {
23
+ expect(() => validateRecipe(recipe({ compressionSourceOnly: 'yes' })))
24
+ .toThrow(/compressionSourceOnly/);
25
+ expect(() => validateRecipe(recipe({ compressionSourceOnly: 1 })))
26
+ .toThrow(/compressionSourceOnly/);
27
+ });
28
+
29
+ test('plumbs through PASSTHROUGH_KEYS into the built strategy config', () => {
30
+ const parsed = validateRecipe(recipe({ compressionSourceOnly: true, summaryParticipant: 'mythos' }));
31
+ const built = buildFrameworkStrategy(parsed, 'claude-fable-5', 'UTC');
32
+ // buildFrameworkStrategy copies PASSTHROUGH_KEYS onto the strategy options;
33
+ // the flag must reach the AutobiographicalStrategy config.
34
+ const cfg = (built as unknown as { config?: Record<string, unknown> }).config
35
+ ?? (built as unknown as Record<string, unknown>);
36
+ expect(cfg.compressionSourceOnly).toBe(true);
37
+ });
38
+ });
@@ -0,0 +1,202 @@
1
+ // Black-box coverage of scripts/release-changelog.ts: each case runs the
2
+ // real script in a throwaway directory, so assembly, validation, and the
3
+ // deletion of consumed fragments are all exercised exactly as `npm version`
4
+ // would run them.
5
+ import { test } from "bun:test";
6
+ import assert from "node:assert/strict";
7
+ import { execFileSync } from "node:child_process";
8
+ import {
9
+ mkdirSync,
10
+ mkdtempSync,
11
+ readdirSync,
12
+ readFileSync,
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { dirname, join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const SCRIPT = fileURLToPath(
20
+ new URL("../scripts/release-changelog.ts", import.meta.url),
21
+ );
22
+
23
+ const BASE_CHANGELOG = [
24
+ "# Changelog",
25
+ "",
26
+ "Intro that mentions ## Unreleased inline, which must not count as a heading.",
27
+ "",
28
+ "## Unreleased",
29
+ "",
30
+ "## 1.0.0 — 2026-01-01",
31
+ "",
32
+ "### Fixed",
33
+ "",
34
+ "- Old entry.",
35
+ "",
36
+ ].join("\n");
37
+
38
+ interface Fixture {
39
+ version?: string;
40
+ changelog?: string;
41
+ fragments?: Record<string, string>;
42
+ }
43
+
44
+ function setup(f: Fixture): string {
45
+ const dir = mkdtempSync(join(tmpdir(), "release-changelog-"));
46
+ writeFileSync(
47
+ join(dir, "package.json"),
48
+ JSON.stringify({ name: "fixture", version: f.version ?? "1.1.0" }),
49
+ );
50
+ writeFileSync(join(dir, "CHANGELOG.md"), f.changelog ?? BASE_CHANGELOG);
51
+ mkdirSync(join(dir, "changelog.d"));
52
+ writeFileSync(join(dir, "changelog.d", "README.md"), "# Pending fragments\n");
53
+ for (const [name, body] of Object.entries(f.fragments ?? {})) {
54
+ const path = join(dir, "changelog.d", name);
55
+ mkdirSync(dirname(path), { recursive: true });
56
+ writeFileSync(path, body);
57
+ }
58
+ return dir;
59
+ }
60
+
61
+ function run(dir: string): { status: number; stdout: string; stderr: string } {
62
+ try {
63
+ const stdout = execFileSync(process.execPath, [SCRIPT], {
64
+ cwd: dir,
65
+ encoding: "utf8",
66
+ stdio: ["ignore", "pipe", "pipe"],
67
+ });
68
+ return { status: 0, stdout, stderr: "" };
69
+ } catch (e) {
70
+ const err = e as { status: number; stdout?: string; stderr?: string };
71
+ return { status: err.status, stdout: err.stdout ?? "", stderr: err.stderr ?? "" };
72
+ }
73
+ }
74
+
75
+ function section(text: string, version: string): string {
76
+ const lines = text.split("\n");
77
+ const start = lines.findIndex((l) => l.startsWith(`## ${version} — `));
78
+ assert.notEqual(start, -1, `no '## ${version}' section in:\n${text}`);
79
+ const rest = lines.slice(start + 1);
80
+ const end = rest.findIndex((l) => l.startsWith("## "));
81
+ return rest.slice(0, end === -1 ? undefined : end).join("\n").trim();
82
+ }
83
+
84
+ const changelog = (dir: string) => readFileSync(join(dir, "CHANGELOG.md"), "utf8");
85
+ const pending = (dir: string) => readdirSync(join(dir, "changelog.d")).sort();
86
+
87
+ test("folds fragments into a versioned section in canonical order and deletes them", () => {
88
+ const dir = setup({
89
+ fragments: {
90
+ "z-later.fixed.md": "- Fixed thing.\n",
91
+ "a-first.added.md": "- Added thing,\n continued on an indented line.\n",
92
+ "m.breaking.md": "- **Module authors:** breaking thing.\n",
93
+ },
94
+ });
95
+ const r = run(dir);
96
+ assert.equal(r.status, 0, r.stderr);
97
+ const text = changelog(dir);
98
+ assert.equal(
99
+ section(text, "1.1.0"),
100
+ [
101
+ "### Breaking",
102
+ "",
103
+ "- **Module authors:** breaking thing.",
104
+ "",
105
+ "### Added",
106
+ "",
107
+ "- Added thing,",
108
+ " continued on an indented line.",
109
+ "",
110
+ "### Fixed",
111
+ "",
112
+ "- Fixed thing.",
113
+ ].join("\n"),
114
+ );
115
+ assert.match(text, /^## Unreleased\n\n## 1\.1\.0 — \d{4}-\d{2}-\d{2}\n/m, "fresh empty Unreleased above the cut");
116
+ assert.ok(text.startsWith("# Changelog\n\nIntro that mentions"), "file header preserved");
117
+ assert.equal(section(text, "1.0.0"), "### Fixed\n\n- Old entry.", "older section untouched");
118
+ assert.deepEqual(pending(dir), ["README.md"], "consumed fragments deleted, README kept");
119
+ });
120
+
121
+ test("merges fragments into directly-filed Unreleased entries, reordering subsections canonically", () => {
122
+ const dir = setup({
123
+ changelog: BASE_CHANGELOG.replace(
124
+ "## Unreleased\n",
125
+ "## Unreleased\n\n### Fixed\n\n- Manual fix.\n\n### Added\n\n- Manual add.\n",
126
+ ),
127
+ fragments: { "x.fixed.md": "- Fragment fix.\n" },
128
+ });
129
+ const r = run(dir);
130
+ assert.equal(r.status, 0, r.stderr);
131
+ assert.equal(
132
+ section(changelog(dir), "1.1.0"),
133
+ "### Added\n\n- Manual add.\n\n### Fixed\n\n- Manual fix.\n\n- Fragment fix.",
134
+ );
135
+ });
136
+
137
+ test("breaking fragments join an audience-qualified Breaking heading", () => {
138
+ const dir = setup({
139
+ changelog: BASE_CHANGELOG.replace(
140
+ "## Unreleased\n",
141
+ "## Unreleased\n\n### Fixed\n\n- Manual fix.\n\n### Breaking (module authors only)\n\n- Manual break.\n",
142
+ ),
143
+ fragments: { "b.breaking.md": "- Fragment break.\n" },
144
+ });
145
+ const r = run(dir);
146
+ assert.equal(r.status, 0, r.stderr);
147
+ assert.equal(
148
+ section(changelog(dir), "1.1.0"),
149
+ "### Breaking (module authors only)\n\n- Manual break.\n\n- Fragment break.\n\n### Fixed\n\n- Manual fix.",
150
+ );
151
+ });
152
+
153
+ test("accepts nested bullets and multi-line continuations", () => {
154
+ const dir = setup({
155
+ fragments: { "n.fixed.md": "- one\n - nested\n more text\n- two\n" },
156
+ });
157
+ const r = run(dir);
158
+ assert.equal(r.status, 0, r.stderr);
159
+ assert.equal(section(changelog(dir), "1.1.0"), "### Fixed\n\n- one\n - nested\n more text\n- two");
160
+ });
161
+
162
+ test("accepts bullet content that merely resembles headings or rules", () => {
163
+ const body = "- **Module authors:** bold opener.\n- -1 is now the sentinel.\n- #123 is referenced inline.\n- ***emphasis*** then text.\n";
164
+ const dir = setup({ fragments: { "r.fixed.md": body } });
165
+ const r = run(dir);
166
+ assert.equal(r.status, 0, r.stderr);
167
+ assert.equal(section(changelog(dir), "1.1.0"), `### Fixed\n\n${body.trim()}`);
168
+ });
169
+
170
+ const refusals: Array<[string, Fixture, RegExp]> = [
171
+ ["nothing to release", {}, /nothing to release as 1\.1\.0/],
172
+ ["duplicate version section", { version: "1.0.0", fragments: { "a.fixed.md": "- x\n" } }, /'## 1\.0\.0' section already exists/],
173
+ ["unrecognized category suffix", { fragments: { "oops.md": "- x\n" } }, /changelog\.d\/oops\.md: unrecognized file/],
174
+ ["stray non-markdown file", { fragments: { "notes.txt": "hi\n", "a.fixed.md": "- x\n" } }, /changelog\.d\/notes\.txt: unrecognized file/],
175
+ ["empty fragment", { fragments: { "e.fixed.md": "\n" } }, /changelog\.d\/e\.fixed\.md: empty fragment/],
176
+ ["fragment nested under a branch-name directory", { fragments: { "fix/foo.fixed.md": "- x\n", "a.fixed.md": "- y\n" } }, /changelog\.d\/fix: not a file/],
177
+ ["plain prose", { fragments: { "p.fixed.md": "prose only\n" } }, /offending line: 'prose only'/],
178
+ ["prose after a valid bullet", { fragments: { "p.fixed.md": "- ok\nrogue prose\n" } }, /offending line: 'rogue prose'/],
179
+ ["top-level heading after a bullet", { fragments: { "h.fixed.md": "- ok\n\n## 9.9.9 — fake\n" } }, /offending line: '## 9\.9\.9 — fake'/],
180
+ ["indented ATX heading", { fragments: { "h.fixed.md": "- ok\n ## 8.8.8 — injected\n - beneath\n" } }, /offending line: ' ## 8\.8\.8 — injected'/],
181
+ ["indented setext underline / rule", { fragments: { "s.fixed.md": "- ok\n ---\n" } }, /offending line: ' ---'/],
182
+ ["spaced thematic break shaped like a bullet", { fragments: { "s.fixed.md": "- ok\n- - -\n" } }, /offending line: '- - -'/],
183
+ ["asterisk thematic break with spaces", { fragments: { "s.fixed.md": "- ok\n* * *\n" } }, /offending line: '\* \* \*'/],
184
+ ["rule as bullet content", { fragments: { "s.fixed.md": "- ---\n" } }, /offending line: '- ---'/],
185
+ ["heading as bullet content", { fragments: { "h.fixed.md": "- ## 9.9.9 — embedded heading\n" } }, /offending line: '- ## 9\.9\.9 — embedded heading'/],
186
+ ["heading as nested bullet content", { fragments: { "h.fixed.md": "- ok\n - ### nested heading\n" } }, /offending line: ' - ### nested heading'/],
187
+ ["tab-indented continuation", { fragments: { "t.fixed.md": "- ok\n\tcontinued\n" } }, /offending line: '\tcontinued'/],
188
+ ["no Unreleased heading", { changelog: "# Changelog\n\n## 1.0.0 — 2026-01-01\n\n- x\n", fragments: { "a.fixed.md": "- x\n" } }, /no '## Unreleased' section/],
189
+ ["two Unreleased headings", { changelog: BASE_CHANGELOG + "\n## Unreleased\n\n- stranded\n", fragments: { "a.fixed.md": "- x\n" } }, /2 '## Unreleased' headings/],
190
+ ];
191
+
192
+ for (const [name, fixture, message] of refusals) {
193
+ test(`refuses ${name} without touching anything`, () => {
194
+ const dir = setup(fixture);
195
+ const before = { changelog: changelog(dir), pending: pending(dir) };
196
+ const r = run(dir);
197
+ assert.equal(r.status, 1, `expected refusal, got exit ${r.status}:\n${r.stdout}${r.stderr}`);
198
+ assert.match(r.stderr, message);
199
+ assert.equal(changelog(dir), before.changelog, "CHANGELOG.md must be untouched");
200
+ assert.deepEqual(pending(dir), before.pending, "no fragment may be deleted on refusal");
201
+ });
202
+ }
@@ -0,0 +1,49 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
2
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import type { ModuleContext } from '@animalabs/agent-framework';
6
+ import {
7
+ WebUiModule,
8
+ __getSharedServerPortForTests,
9
+ __resetSharedServerForTests,
10
+ } from '../src/modules/web-ui-module.js';
11
+
12
+ describe('retrieval operator authentication on credential-free loopback', () => {
13
+ let webUiModule: WebUiModule | undefined;
14
+ let root: string;
15
+ let baseUrl: string;
16
+
17
+ beforeAll(async () => {
18
+ root = mkdtempSync(join(tmpdir(), 'retrieval-auth-loopback-'));
19
+ const staticRoot = join(root, 'web');
20
+ mkdirSync(staticRoot);
21
+ writeFileSync(join(staticRoot, 'index.html'), '<!doctype html><title>loopback</title>');
22
+ webUiModule = new WebUiModule({
23
+ port: 0,
24
+ host: '127.0.0.1',
25
+ staticDir: staticRoot,
26
+ });
27
+ await webUiModule.start({} as ModuleContext);
28
+ const port = __getSharedServerPortForTests();
29
+ if (!port) throw new Error('webui server not bound; did start() succeed?');
30
+ baseUrl = `http://127.0.0.1:${port}`;
31
+ });
32
+
33
+ afterAll(async () => {
34
+ await webUiModule?.stop();
35
+ await __resetSharedServerForTests();
36
+ if (root) rmSync(root, { recursive: true, force: true });
37
+ });
38
+
39
+ test('denies both retrieval routes without changing ordinary loopback routes', async () => {
40
+ expect((await fetch(`${baseUrl}/`)).status).toBe(200);
41
+ expect((await fetch(`${baseUrl}/debug/context`)).status).toBe(503);
42
+
43
+ for (const path of ['/debug/retrieval', '/debug/retrieval/view']) {
44
+ const response = await fetch(`${baseUrl}${path}`);
45
+ expect(response.status).toBe(401);
46
+ expect(response.headers.get('cache-control')).toBe('no-store');
47
+ }
48
+ });
49
+ });
@@ -0,0 +1,74 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { Membrane } from '@animalabs/membrane';
3
+ import { validateRecipe } from '../src/recipe.js';
4
+ import { buildRetrievalModuleConfig } from '../src/retrieval-config.js';
5
+
6
+ const membrane = {} as Membrane;
7
+
8
+ function recipe(retrieval: unknown, provider: string = 'openai-codex') {
9
+ return {
10
+ name: 'retrieval-config-test',
11
+ agent: { systemPrompt: 'sys', provider },
12
+ modules: { retrieval },
13
+ };
14
+ }
15
+
16
+ describe('retrieval recipe config', () => {
17
+ test('accepts and maps provider reasoning settings', () => {
18
+ const parsed = validateRecipe(recipe({
19
+ model: 'test-model',
20
+ maxInjected: 7,
21
+ reasoningEffort: 'minimal',
22
+ }));
23
+
24
+ expect(parsed.modules?.retrieval).toEqual({
25
+ model: 'test-model',
26
+ maxInjected: 7,
27
+ reasoningEffort: 'minimal',
28
+ });
29
+ expect(buildRetrievalModuleConfig(membrane, parsed.modules!.retrieval!, 'openai-codex')).toEqual({
30
+ membrane,
31
+ retrievalModel: 'test-model',
32
+ maxInjectedLessons: 7,
33
+ retrievalReasoning: { effort: 'minimal' },
34
+ });
35
+ });
36
+
37
+ test('preserves boolean shorthand and omits unconfigured reasoning', () => {
38
+ expect(validateRecipe(recipe(true)).modules?.retrieval).toBe(true);
39
+ expect(validateRecipe(recipe(false)).modules?.retrieval).toBe(false);
40
+ expect(buildRetrievalModuleConfig(membrane, { model: 'test-model' }, 'anthropic')).toEqual({
41
+ membrane,
42
+ retrievalModel: 'test-model',
43
+ });
44
+ });
45
+
46
+ test('rejects malformed retrieval reasoning settings', () => {
47
+ expect(() => validateRecipe(recipe(null))).toThrow(/modules\.retrieval must be a boolean or object/);
48
+ expect(() => validateRecipe(recipe([]))).toThrow(/modules\.retrieval must be a boolean or object/);
49
+ expect(() => validateRecipe(recipe({ reasoningEffort: 'ultra' }))).toThrow(/reasoningEffort/);
50
+ expect(() => validateRecipe(recipe({ reasoningEffort: ['high'] }))).toThrow(/reasoningEffort/);
51
+ expect(() => validateRecipe(recipe({ reasoningContext: 'current_turn' }))).toThrow(
52
+ /independent one-shot requests/,
53
+ );
54
+ expect(() => validateRecipe(recipe({ reasoningEffort: 'high' }, 'anthropic'))).toThrow(
55
+ /requires agent\.provider/,
56
+ );
57
+ expect(() => buildRetrievalModuleConfig(
58
+ membrane,
59
+ { reasoningEffort: 'high' },
60
+ 'anthropic',
61
+ )).toThrow(/requires agent\.provider/);
62
+ expect(() => validateRecipe(recipe({ reasoningEffort: 'high' }))).toThrow(
63
+ /model must be a non-empty string/,
64
+ );
65
+ expect(() => validateRecipe(recipe({ model: ' ', reasoningEffort: 'high' }))).toThrow(
66
+ /model must be a non-empty string/,
67
+ );
68
+ expect(() => buildRetrievalModuleConfig(
69
+ membrane,
70
+ { reasoningEffort: 'high' },
71
+ 'openai-codex',
72
+ )).toThrow(/model must be a non-empty string/);
73
+ });
74
+ });