@animalabs/connectome-host 0.3.1

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 (123) hide show
  1. package/.env.example +20 -0
  2. package/.github/workflows/publish.yml +65 -0
  3. package/ARCHITECTURE.md +355 -0
  4. package/CHANGELOG.md +30 -0
  5. package/HEADLESS-FLEET-PLAN.md +330 -0
  6. package/README.md +189 -0
  7. package/UNIFIED-TREE-PLAN.md +242 -0
  8. package/bun.lock +288 -0
  9. package/docs/AGENT-MEMORY-GUIDE.md +214 -0
  10. package/docs/AGENT-ONBOARDING.md +541 -0
  11. package/docs/ATTENTION-AND-GATING.md +120 -0
  12. package/docs/DEPLOYMENTS.md +130 -0
  13. package/docs/DEV-ENVIRONMENT.md +215 -0
  14. package/docs/LIBRARY-PIPELINE.md +286 -0
  15. package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
  16. package/docs/claudeai-evacuation.md +228 -0
  17. package/docs/debug-context-api.md +208 -0
  18. package/docs/webui-deployment.md +219 -0
  19. package/package.json +33 -0
  20. package/recipes/SETUP.md +308 -0
  21. package/recipes/TRIUMVIRATE-SETUP.md +467 -0
  22. package/recipes/claude-export-revive.json +19 -0
  23. package/recipes/clerk.json +157 -0
  24. package/recipes/knowledge-miner.json +174 -0
  25. package/recipes/knowledge-reviewer.json +76 -0
  26. package/recipes/mcpl-editor-test.json +38 -0
  27. package/recipes/prompts/transplant-addendum.md +17 -0
  28. package/recipes/triumvirate.json +63 -0
  29. package/recipes/webui-fleet-test.json +27 -0
  30. package/recipes/webui-test.json +16 -0
  31. package/recipes/zulip-miner.json +87 -0
  32. package/scripts/connectome-doctor +373 -0
  33. package/scripts/evacuator.ts +956 -0
  34. package/scripts/import-claudeai-export.ts +747 -0
  35. package/scripts/lib/line-reader.ts +53 -0
  36. package/scripts/test-historical-thinking.ts +148 -0
  37. package/scripts/warmup-session.ts +336 -0
  38. package/src/agent-name.ts +58 -0
  39. package/src/commands.ts +1074 -0
  40. package/src/headless.ts +528 -0
  41. package/src/index.ts +867 -0
  42. package/src/logging-adapter.ts +208 -0
  43. package/src/mcpl-config.ts +199 -0
  44. package/src/modules/activity-module.ts +270 -0
  45. package/src/modules/channel-mode-module.ts +260 -0
  46. package/src/modules/fleet-module.ts +1705 -0
  47. package/src/modules/fleet-types.ts +225 -0
  48. package/src/modules/lessons-module.ts +465 -0
  49. package/src/modules/mcpl-admin-module.ts +345 -0
  50. package/src/modules/retrieval-module.ts +327 -0
  51. package/src/modules/settings-module.ts +143 -0
  52. package/src/modules/subagent-module.ts +2074 -0
  53. package/src/modules/subscription-gc-module.ts +322 -0
  54. package/src/modules/time-module.ts +85 -0
  55. package/src/modules/tui-module.ts +76 -0
  56. package/src/modules/web-ui-curve-page.ts +249 -0
  57. package/src/modules/web-ui-module.ts +2595 -0
  58. package/src/recipe.ts +1003 -0
  59. package/src/session-manager.ts +278 -0
  60. package/src/state/agent-tree-reducer.ts +431 -0
  61. package/src/state/fleet-tree-aggregator.ts +195 -0
  62. package/src/strategies/frontdesk-strategy.ts +364 -0
  63. package/src/synesthete.ts +68 -0
  64. package/src/tui.ts +2200 -0
  65. package/src/types/bun-ffi.d.ts +6 -0
  66. package/src/web/protocol.ts +648 -0
  67. package/test/agent-name.test.ts +62 -0
  68. package/test/agent-tree-fleet-launch.test.ts +64 -0
  69. package/test/agent-tree-reducer-parity.test.ts +194 -0
  70. package/test/agent-tree-reducer.test.ts +443 -0
  71. package/test/agent-tree-rollup.test.ts +109 -0
  72. package/test/autobio-progress-snapshot.test.ts +40 -0
  73. package/test/claudeai-export-importer.test.ts +386 -0
  74. package/test/evacuator.test.ts +332 -0
  75. package/test/fleet-commands.test.ts +244 -0
  76. package/test/fleet-no-subfleets.test.ts +147 -0
  77. package/test/fleet-orchestration.test.ts +353 -0
  78. package/test/fleet-recipe.test.ts +124 -0
  79. package/test/fleet-route.test.ts +44 -0
  80. package/test/fleet-smoke.test.ts +479 -0
  81. package/test/fleet-subscribe-union-e2e.test.ts +123 -0
  82. package/test/fleet-subscribe-union.test.ts +85 -0
  83. package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
  84. package/test/fleet-tree-aggregator.test.ts +215 -0
  85. package/test/frontdesk-strategy.test.ts +326 -0
  86. package/test/headless-describe.test.ts +182 -0
  87. package/test/headless-smoke.test.ts +190 -0
  88. package/test/logging-adapter.test.ts +87 -0
  89. package/test/mcpl-admin-module.test.ts +213 -0
  90. package/test/mcpl-agent-overlay.test.ts +126 -0
  91. package/test/mock-headless-child.ts +133 -0
  92. package/test/recipe-cache-ttl.test.ts +37 -0
  93. package/test/recipe-env.test.ts +157 -0
  94. package/test/recipe-path-resolution.test.ts +170 -0
  95. package/test/subagent-async-timeout.test.ts +381 -0
  96. package/test/subagent-fork-materialise.test.ts +241 -0
  97. package/test/subagent-peek-scoping.test.ts +180 -0
  98. package/test/subagent-peek-zombie.test.ts +148 -0
  99. package/test/subagent-reaper-in-flight.test.ts +229 -0
  100. package/test/subscription-gc-module.test.ts +136 -0
  101. package/test/warmup-handoff.test.ts +150 -0
  102. package/test/web-ui-bind.test.ts +51 -0
  103. package/test/web-ui-module.test.ts +246 -0
  104. package/test/web-ui-protocol.test.ts +0 -0
  105. package/tsconfig.json +14 -0
  106. package/web/bun.lock +357 -0
  107. package/web/index.html +12 -0
  108. package/web/package.json +24 -0
  109. package/web/src/App.tsx +1931 -0
  110. package/web/src/Context.tsx +158 -0
  111. package/web/src/ContextDocument.tsx +150 -0
  112. package/web/src/Files.tsx +271 -0
  113. package/web/src/Lessons.tsx +164 -0
  114. package/web/src/Mcpl.tsx +310 -0
  115. package/web/src/Stream.tsx +119 -0
  116. package/web/src/TreeSidebar.tsx +283 -0
  117. package/web/src/Usage.tsx +182 -0
  118. package/web/src/main.tsx +7 -0
  119. package/web/src/styles.css +26 -0
  120. package/web/src/tree.ts +268 -0
  121. package/web/src/wire.ts +120 -0
  122. package/web/tsconfig.json +21 -0
  123. package/web/vite.config.ts +32 -0
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Guard tests for the import → warmup → conhost identity handoff.
3
+ *
4
+ * The original bug: warmup-session.ts called ContextManager.open without a
5
+ * namespace argument, so AutobiographicalStrategy persisted summaries to
6
+ * the no-namespace default slot `default/autobio:summaries`. The live
7
+ * framework reads from `agents/${name}/autobio:summaries`. The two never
8
+ * met; the agent saw an empty autobiography on first open.
9
+ *
10
+ * The fix is in two parts:
11
+ * 1. `SessionManager.getImportSource` surfaces the agentName the
12
+ * importer wrote into the sidecar, so warmup can derive a name
13
+ * without an explicit CLI flag.
14
+ * 2. `warmup-session.ts` passes `namespace: 'agents/' + agentName`
15
+ * to `ContextManager.open` so its writes land where the framework
16
+ * reads.
17
+ *
18
+ * These tests pin both contracts. They don't run an LLM — they just
19
+ * check the surface area that has to remain intact for the handoff to
20
+ * work end-to-end.
21
+ */
22
+
23
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
24
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
25
+ import { tmpdir } from 'node:os';
26
+ import { join } from 'node:path';
27
+ import { JsStore } from '@animalabs/chronicle';
28
+ import { ContextManager } from '@animalabs/context-manager';
29
+ import { AutobiographicalStrategy } from '@animalabs/agent-framework';
30
+ import { SessionManager } from '../src/session-manager.js';
31
+
32
+ describe('SessionManager.getImportSource', () => {
33
+ let tmpDir: string;
34
+ let sm: SessionManager;
35
+
36
+ beforeEach(() => {
37
+ tmpDir = mkdtempSync(join(tmpdir(), 'warmup-handoff-'));
38
+ mkdirSync(join(tmpDir, 'sessions'), { recursive: true });
39
+ sm = new SessionManager(tmpDir);
40
+ });
41
+
42
+ afterEach(() => {
43
+ rmSync(tmpDir, { recursive: true, force: true });
44
+ });
45
+
46
+ test('returns null when no sidecar exists', () => {
47
+ expect(sm.getImportSource('nonexistent')).toBeNull();
48
+ });
49
+
50
+ test('returns parsed JSON when sidecar exists', () => {
51
+ const sidecarPath = join(tmpDir, 'sessions', 'abc123.import-source.json');
52
+ writeFileSync(sidecarPath, JSON.stringify({ agentName: 'Claude', name: 'Truvari' }));
53
+ const result = sm.getImportSource('abc123');
54
+ expect(result).toEqual({ agentName: 'Claude', name: 'Truvari' });
55
+ });
56
+
57
+ test('returns null when sidecar contents are not valid JSON', () => {
58
+ const sidecarPath = join(tmpDir, 'sessions', 'broken.import-source.json');
59
+ writeFileSync(sidecarPath, '{not valid json');
60
+ expect(sm.getImportSource('broken')).toBeNull();
61
+ });
62
+
63
+ test('returns null when sidecar JSON parses to a non-object root', () => {
64
+ // The trust-boundary gate added in src/session-manager.ts:
65
+ // valid JSON like `null`, `[]`, `"string"`, or a bare number parses
66
+ // successfully but would have produced an unusable `ImportSource`
67
+ // with whatever field accesses the call site does. The gate rejects
68
+ // these at the boundary so the rest of the code can assume a record.
69
+ const cases: Array<[string, string]> = [
70
+ ['null-root', 'null'],
71
+ ['array-root', '[1,2,3]'],
72
+ ['string-root', '"just a string"'],
73
+ ['number-root', '42'],
74
+ ];
75
+ for (const [id, body] of cases) {
76
+ writeFileSync(join(tmpDir, 'sessions', `${id}.import-source.json`), body);
77
+ expect(sm.getImportSource(id)).toBeNull();
78
+ }
79
+ });
80
+
81
+ test('surfaces agentName specifically — guards against field rename', () => {
82
+ // The whole point of this sidecar field is that warmup-session.ts can
83
+ // read it back. If someone renames `agentName` upstream without
84
+ // migrating callers, both warmup and conhost lose their fallback and
85
+ // we silently regress to the original bug.
86
+ const sidecarPath = join(tmpDir, 'sessions', 'guard.import-source.json');
87
+ writeFileSync(sidecarPath, JSON.stringify({ agentName: 'TestAgent' }));
88
+ const result = sm.getImportSource('guard');
89
+ expect(result?.agentName).toBe('TestAgent');
90
+ });
91
+ });
92
+
93
+ describe('AutobiographicalStrategy state slot under explicit namespace', () => {
94
+ let tmpDir: string;
95
+
96
+ beforeEach(() => {
97
+ tmpDir = mkdtempSync(join(tmpdir(), 'warmup-namespace-'));
98
+ });
99
+
100
+ afterEach(() => {
101
+ rmSync(tmpDir, { recursive: true, force: true });
102
+ });
103
+
104
+ test("registers 'agents/${name}/autobio:summaries' when namespace is passed", async () => {
105
+ const storePath = join(tmpDir, 'store');
106
+ const store = JsStore.openOrCreate({ path: storePath });
107
+ try {
108
+ const strategy = new AutobiographicalStrategy({
109
+ compressionModel: 'claude-sonnet-4-5-20250929',
110
+ autoTickOnNewMessage: false,
111
+ });
112
+ await ContextManager.open({
113
+ store,
114
+ strategy,
115
+ namespace: 'agents/Claude',
116
+ });
117
+
118
+ const stateIds = store.listStates().map((s: { id: string }) => s.id);
119
+ // The slot the framework's main-agent ContextManager registers.
120
+ // If warmup-session.ts ever drops its namespace argument again, this
121
+ // assertion fails because the strategy lands at `default/` instead.
122
+ expect(stateIds).toContain('agents/Claude/autobio:summaries');
123
+ expect(stateIds).not.toContain('default/autobio:summaries');
124
+ } finally {
125
+ store.close?.();
126
+ }
127
+ });
128
+
129
+ test("falls back to 'default/autobio:summaries' when namespace is omitted (regression-witness)", async () => {
130
+ // This is intentionally NOT what warmup wants — it documents the
131
+ // failure mode and ensures the namespace-on path stays distinct from
132
+ // the namespace-off path. If both ended up at the same slot the
133
+ // first test above would be a tautology.
134
+ const storePath = join(tmpDir, 'store');
135
+ const store = JsStore.openOrCreate({ path: storePath });
136
+ try {
137
+ const strategy = new AutobiographicalStrategy({
138
+ compressionModel: 'claude-sonnet-4-5-20250929',
139
+ autoTickOnNewMessage: false,
140
+ });
141
+ await ContextManager.open({ store, strategy });
142
+
143
+ const stateIds = store.listStates().map((s: { id: string }) => s.id);
144
+ expect(stateIds).toContain('default/autobio:summaries');
145
+ expect(stateIds).not.toContain('agents/Claude/autobio:summaries');
146
+ } finally {
147
+ store.close?.();
148
+ }
149
+ });
150
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Bind-safety tests for WebUiModule.start().
3
+ *
4
+ * The security-critical invariant: the default bind is 0.0.0.0 (connectome
5
+ * deployments are remote), and any non-loopback bind hard-requires Basic-Auth.
6
+ * A recipe that turns on webui without credentials must fail loudly at startup
7
+ * rather than silently exposing an unauthenticated admin surface — which now
8
+ * includes /debug/context, dumping the full system prompt + conversation.
9
+ *
10
+ * These live in their own file because assertSafeBind only runs when no
11
+ * process-level singleton server exists yet; Bun runs each test file in its
12
+ * own process, so the singleton starts null here.
13
+ */
14
+ import { describe, test, expect, afterEach } from 'bun:test';
15
+ import type { ModuleContext } from '@animalabs/agent-framework';
16
+ import {
17
+ WebUiModule,
18
+ __resetSharedServerForTests,
19
+ } from '../src/modules/web-ui-module.js';
20
+
21
+ afterEach(async () => {
22
+ await __resetSharedServerForTests();
23
+ });
24
+
25
+ describe('WebUiModule bind safety', () => {
26
+ test('default bind (0.0.0.0) without basicAuth refuses to start', async () => {
27
+ const mod = new WebUiModule({ port: 0 }); // no host → default 0.0.0.0, no auth
28
+ await expect(mod.start({} as ModuleContext)).rejects.toThrow(/without auth/i);
29
+ });
30
+
31
+ test('explicit non-loopback host without basicAuth refuses to start', async () => {
32
+ const mod = new WebUiModule({ port: 0, host: '0.0.0.0' });
33
+ await expect(mod.start({} as ModuleContext)).rejects.toThrow(/without auth/i);
34
+ });
35
+
36
+ test('loopback bind without basicAuth is allowed (local dev)', async () => {
37
+ const mod = new WebUiModule({ port: 0, host: '127.0.0.1' });
38
+ await mod.start({} as ModuleContext); // must not throw
39
+ await mod.stop();
40
+ });
41
+
42
+ test('non-loopback bind WITH basicAuth is allowed', async () => {
43
+ const mod = new WebUiModule({
44
+ port: 0,
45
+ host: '0.0.0.0',
46
+ basicAuth: { username: 'admin', password: 'open-sesame' },
47
+ });
48
+ await mod.start({} as ModuleContext); // must not throw
49
+ await mod.stop();
50
+ });
51
+ });
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Smoke tests for the WebUiModule HTTP / WS surface.
3
+ *
4
+ * Coverage focus:
5
+ * - Origin allowlist on `/ws`: cross-origin attempts get 403, same-origin
6
+ * succeeds. (Pre-fix this was undefended — see QA emergency #2.)
7
+ * - Basic-auth: 401 without/with-wrong creds, 200/upgrade with right creds.
8
+ * - serveStatic path containment: a `/..` request for a sibling-prefixed
9
+ * directory (`<root>-evil`) doesn't escape staticRoot. (Pre-fix the
10
+ * startsWith check missed this; see QA #13.)
11
+ * - WS round-trip: client sends `{type:'ping'}` → server holds the
12
+ * connection (no reply, but no disconnect either).
13
+ * - WS rejects invalid JSON / unknown shapes with an error envelope.
14
+ *
15
+ * The module uses a process-level singleton (HTTP server outlives any
16
+ * single framework lifetime), so we boot once per file and share across
17
+ * tests. Bun test runs each test file in its own process, which keeps the
18
+ * singleton scope tight.
19
+ */
20
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
21
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
22
+ import { tmpdir } from 'node:os';
23
+ import { join } from 'node:path';
24
+ import type { ModuleContext } from '@animalabs/agent-framework';
25
+ import {
26
+ WebUiModule,
27
+ __getSharedServerPortForTests,
28
+ __resetSharedServerForTests,
29
+ } from '../src/modules/web-ui-module.js';
30
+
31
+ interface ServerHandle {
32
+ port: number;
33
+ staticRoot: string;
34
+ cleanup: () => Promise<void>;
35
+ }
36
+
37
+ const BASIC_USER = 'admin';
38
+ const BASIC_PASS = 'open-sesame';
39
+
40
+ let handle: ServerHandle;
41
+
42
+ function basicAuthHeader(user: string, pass: string): string {
43
+ return `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`;
44
+ }
45
+
46
+ beforeAll(async () => {
47
+ const tmp = mkdtempSync(join(tmpdir(), 'webui-test-'));
48
+ const staticRoot = join(tmp, 'web-bundle');
49
+ mkdirSync(staticRoot, { recursive: true });
50
+ writeFileSync(join(staticRoot, 'index.html'), '<!doctype html><title>t</title>');
51
+ writeFileSync(join(staticRoot, 'app.js'), 'console.log("hi")');
52
+
53
+ // Sibling directory used by the path-containment escape test. The pre-fix
54
+ // `startsWith(root)` check would let `<root>-evil` slip through; the
55
+ // current `startsWith(root + sep)` does not.
56
+ const sibling = `${staticRoot}-evil`;
57
+ mkdirSync(sibling, { recursive: true });
58
+ writeFileSync(join(sibling, 'secret.txt'), 'pwned');
59
+
60
+ // Bun.serve supports port=0 → OS picks a free port; we read it back from
61
+ // the server. Avoids collisions when the test harness retries.
62
+ const mod = new WebUiModule({
63
+ port: 0,
64
+ host: '127.0.0.1',
65
+ basicAuth: { username: BASIC_USER, password: BASIC_PASS },
66
+ staticDir: staticRoot,
67
+ });
68
+ await mod.start({} as ModuleContext);
69
+
70
+ const port = __getSharedServerPortForTests();
71
+ if (!port) throw new Error('webui server not bound; did start() succeed?');
72
+
73
+ handle = {
74
+ port,
75
+ staticRoot,
76
+ cleanup: async () => {
77
+ await mod.stop();
78
+ await __resetSharedServerForTests();
79
+ rmSync(tmp, { recursive: true, force: true });
80
+ rmSync(sibling, { recursive: true, force: true });
81
+ },
82
+ };
83
+ });
84
+
85
+ afterAll(async () => {
86
+ await handle.cleanup();
87
+ });
88
+
89
+ describe('WebUiModule HTTP', () => {
90
+ test('GET / requires basic auth', async () => {
91
+ const res = await fetch(`http://127.0.0.1:${handle.port}/`);
92
+ expect(res.status).toBe(401);
93
+ expect(res.headers.get('www-authenticate') ?? '').toContain('Basic');
94
+ });
95
+
96
+ test('GET / with correct creds returns the SPA shell', async () => {
97
+ const res = await fetch(`http://127.0.0.1:${handle.port}/`, {
98
+ headers: { authorization: basicAuthHeader(BASIC_USER, BASIC_PASS) },
99
+ });
100
+ expect(res.status).toBe(200);
101
+ const body = await res.text();
102
+ expect(body).toContain('<!doctype html>');
103
+ });
104
+
105
+ test('GET / with wrong password is rejected', async () => {
106
+ const res = await fetch(`http://127.0.0.1:${handle.port}/`, {
107
+ headers: { authorization: basicAuthHeader(BASIC_USER, 'wrong') },
108
+ });
109
+ expect(res.status).toBe(401);
110
+ });
111
+
112
+ test('GET / with wrong username is rejected', async () => {
113
+ const res = await fetch(`http://127.0.0.1:${handle.port}/`, {
114
+ headers: { authorization: basicAuthHeader('attacker', BASIC_PASS) },
115
+ });
116
+ expect(res.status).toBe(401);
117
+ });
118
+
119
+ test('GET /app.js returns the asset', async () => {
120
+ const res = await fetch(`http://127.0.0.1:${handle.port}/app.js`, {
121
+ headers: { authorization: basicAuthHeader(BASIC_USER, BASIC_PASS) },
122
+ });
123
+ expect(res.status).toBe(200);
124
+ const body = await res.text();
125
+ expect(body).toContain('console.log');
126
+ });
127
+
128
+ // Path containment: a request for /../<sibling-of-staticRoot>/secret.txt
129
+ // would, pre-fix, slip past the `startsWith(root)` check because the
130
+ // sibling directory's path begins with `<staticRoot>-evil`. The new check
131
+ // requires either an exact match or `<root>+pathSep`. We can't easily
132
+ // express the relative `..` traversal in a URL because the router
133
+ // normalizes `/`-relative requests to staticRoot — but we *can* assert
134
+ // that the SPA fallback (returns index.html) kicks in for unknown paths
135
+ // rather than serving the sibling directory's file.
136
+ test('unknown / out-of-tree paths fall through to SPA shell, never to siblings', async () => {
137
+ const res = await fetch(
138
+ `http://127.0.0.1:${handle.port}/../web-bundle-evil/secret.txt`,
139
+ { headers: { authorization: basicAuthHeader(BASIC_USER, BASIC_PASS) } },
140
+ );
141
+ // URL normalization in fetch + the server resolves under staticRoot.
142
+ // The body must NOT be the secret file, regardless of which fallback
143
+ // path the server takes (404, SPA shell, or 403).
144
+ const body = res.status < 500 ? await res.text() : '';
145
+ expect(body).not.toContain('pwned');
146
+ });
147
+ });
148
+
149
+ describe('WebUiModule WebSocket', () => {
150
+ test('upgrade rejects mismatched Origin with 403', async () => {
151
+ // Browsers always send Origin on WS upgrades; an attacker page would
152
+ // send its own origin, not the host's. With creds present (so we hit
153
+ // the actual Origin check) the server must reply 403 before auth runs.
154
+ const res = await fetch(`http://127.0.0.1:${handle.port}/ws`, {
155
+ headers: {
156
+ authorization: basicAuthHeader(BASIC_USER, BASIC_PASS),
157
+ origin: 'http://evil.example.com',
158
+ upgrade: 'websocket',
159
+ connection: 'Upgrade',
160
+ 'sec-websocket-key': 'dGhlIHNhbXBsZSBub25jZQ==',
161
+ 'sec-websocket-version': '13',
162
+ },
163
+ });
164
+ expect(res.status).toBe(403);
165
+ });
166
+
167
+ test('upgrade rejects with 401 when Origin is OK but auth is missing', async () => {
168
+ const res = await fetch(`http://127.0.0.1:${handle.port}/ws`, {
169
+ headers: {
170
+ origin: `http://127.0.0.1:${handle.port}`,
171
+ upgrade: 'websocket',
172
+ connection: 'Upgrade',
173
+ 'sec-websocket-key': 'dGhlIHNhbXBsZSBub25jZQ==',
174
+ 'sec-websocket-version': '13',
175
+ },
176
+ });
177
+ expect(res.status).toBe(401);
178
+ });
179
+
180
+ test('round-trips a ping with same-origin creds', async () => {
181
+ const ws = new WebSocket(`ws://127.0.0.1:${handle.port}/ws`, {
182
+ // Bun's WebSocket constructor takes a `headers` option in the second
183
+ // arg as an object; emulate the browser by sending Origin from
184
+ // 127.0.0.1:<port> and tacking on Authorization.
185
+ headers: {
186
+ origin: `http://127.0.0.1:${handle.port}`,
187
+ authorization: basicAuthHeader(BASIC_USER, BASIC_PASS),
188
+ },
189
+ } as unknown as undefined);
190
+
191
+ await new Promise<void>((resolve, reject) => {
192
+ const t = setTimeout(() => reject(new Error('ws never opened')), 5000);
193
+ ws.addEventListener('open', () => { clearTimeout(t); resolve(); });
194
+ ws.addEventListener('error', (e) => { clearTimeout(t); reject(e as unknown as Error); });
195
+ });
196
+
197
+ // No app is bound to this module instance, so any non-ping message gets
198
+ // an immediate `host not ready` error envelope. ping is the one variant
199
+ // that doesn't require app state — it's the canary.
200
+ const errored = new Promise<unknown>((resolve) => {
201
+ ws.addEventListener('message', (ev) => resolve(JSON.parse(String(ev.data))));
202
+ });
203
+ ws.send(JSON.stringify({ type: 'mcpl-add', id: 'x', command: '/x' }));
204
+ const reply = (await errored) as { type?: string; message?: string };
205
+ // Without setApp(), the server short-circuits with `host not ready`.
206
+ // Either way, it must be an `error` envelope — never a silent success.
207
+ expect(reply.type).toBe('error');
208
+ expect(typeof reply.message).toBe('string');
209
+
210
+ ws.close();
211
+ });
212
+
213
+ test('rejects invalid JSON with an error envelope, then unknown-shape too', async () => {
214
+ const ws = new WebSocket(`ws://127.0.0.1:${handle.port}/ws`, {
215
+ headers: {
216
+ origin: `http://127.0.0.1:${handle.port}`,
217
+ authorization: basicAuthHeader(BASIC_USER, BASIC_PASS),
218
+ },
219
+ } as unknown as undefined);
220
+
221
+ await new Promise<void>((resolve, reject) => {
222
+ const t = setTimeout(() => reject(new Error('ws never opened')), 5000);
223
+ ws.addEventListener('open', () => { clearTimeout(t); resolve(); });
224
+ ws.addEventListener('error', (e) => { clearTimeout(t); reject(e as unknown as Error); });
225
+ });
226
+
227
+ const replies: Array<{ type?: string; message?: string }> = [];
228
+ const got = new Promise<void>((resolve) => {
229
+ ws.addEventListener('message', (ev) => {
230
+ replies.push(JSON.parse(String(ev.data)));
231
+ if (replies.length >= 2) resolve();
232
+ });
233
+ });
234
+
235
+ ws.send('not-json{{');
236
+ ws.send(JSON.stringify({ type: 'no-such-message' }));
237
+ await got;
238
+
239
+ expect(replies[0]?.type).toBe('error');
240
+ expect(replies[0]?.message).toBe('invalid JSON');
241
+ expect(replies[1]?.type).toBe('error');
242
+ expect(replies[1]?.message).toBe('unknown message shape');
243
+
244
+ ws.close();
245
+ });
246
+ });
Binary file
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist",
10
+ "rootDir": "src"
11
+ },
12
+ "include": ["src"],
13
+ "exclude": ["node_modules", "dist"]
14
+ }