@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.
- package/.env.example +20 -0
- package/.github/workflows/publish.yml +65 -0
- package/ARCHITECTURE.md +355 -0
- package/CHANGELOG.md +30 -0
- package/HEADLESS-FLEET-PLAN.md +330 -0
- package/README.md +189 -0
- package/UNIFIED-TREE-PLAN.md +242 -0
- package/bun.lock +288 -0
- package/docs/AGENT-MEMORY-GUIDE.md +214 -0
- package/docs/AGENT-ONBOARDING.md +541 -0
- package/docs/ATTENTION-AND-GATING.md +120 -0
- package/docs/DEPLOYMENTS.md +130 -0
- package/docs/DEV-ENVIRONMENT.md +215 -0
- package/docs/LIBRARY-PIPELINE.md +286 -0
- package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
- package/docs/claudeai-evacuation.md +228 -0
- package/docs/debug-context-api.md +208 -0
- package/docs/webui-deployment.md +219 -0
- package/package.json +33 -0
- package/recipes/SETUP.md +308 -0
- package/recipes/TRIUMVIRATE-SETUP.md +467 -0
- package/recipes/claude-export-revive.json +19 -0
- package/recipes/clerk.json +157 -0
- package/recipes/knowledge-miner.json +174 -0
- package/recipes/knowledge-reviewer.json +76 -0
- package/recipes/mcpl-editor-test.json +38 -0
- package/recipes/prompts/transplant-addendum.md +17 -0
- package/recipes/triumvirate.json +63 -0
- package/recipes/webui-fleet-test.json +27 -0
- package/recipes/webui-test.json +16 -0
- package/recipes/zulip-miner.json +87 -0
- package/scripts/connectome-doctor +373 -0
- package/scripts/evacuator.ts +956 -0
- package/scripts/import-claudeai-export.ts +747 -0
- package/scripts/lib/line-reader.ts +53 -0
- package/scripts/test-historical-thinking.ts +148 -0
- package/scripts/warmup-session.ts +336 -0
- package/src/agent-name.ts +58 -0
- package/src/commands.ts +1074 -0
- package/src/headless.ts +528 -0
- package/src/index.ts +867 -0
- package/src/logging-adapter.ts +208 -0
- package/src/mcpl-config.ts +199 -0
- package/src/modules/activity-module.ts +270 -0
- package/src/modules/channel-mode-module.ts +260 -0
- package/src/modules/fleet-module.ts +1705 -0
- package/src/modules/fleet-types.ts +225 -0
- package/src/modules/lessons-module.ts +465 -0
- package/src/modules/mcpl-admin-module.ts +345 -0
- package/src/modules/retrieval-module.ts +327 -0
- package/src/modules/settings-module.ts +143 -0
- package/src/modules/subagent-module.ts +2074 -0
- package/src/modules/subscription-gc-module.ts +322 -0
- package/src/modules/time-module.ts +85 -0
- package/src/modules/tui-module.ts +76 -0
- package/src/modules/web-ui-curve-page.ts +249 -0
- package/src/modules/web-ui-module.ts +2595 -0
- package/src/recipe.ts +1003 -0
- package/src/session-manager.ts +278 -0
- package/src/state/agent-tree-reducer.ts +431 -0
- package/src/state/fleet-tree-aggregator.ts +195 -0
- package/src/strategies/frontdesk-strategy.ts +364 -0
- package/src/synesthete.ts +68 -0
- package/src/tui.ts +2200 -0
- package/src/types/bun-ffi.d.ts +6 -0
- package/src/web/protocol.ts +648 -0
- package/test/agent-name.test.ts +62 -0
- package/test/agent-tree-fleet-launch.test.ts +64 -0
- package/test/agent-tree-reducer-parity.test.ts +194 -0
- package/test/agent-tree-reducer.test.ts +443 -0
- package/test/agent-tree-rollup.test.ts +109 -0
- package/test/autobio-progress-snapshot.test.ts +40 -0
- package/test/claudeai-export-importer.test.ts +386 -0
- package/test/evacuator.test.ts +332 -0
- package/test/fleet-commands.test.ts +244 -0
- package/test/fleet-no-subfleets.test.ts +147 -0
- package/test/fleet-orchestration.test.ts +353 -0
- package/test/fleet-recipe.test.ts +124 -0
- package/test/fleet-route.test.ts +44 -0
- package/test/fleet-smoke.test.ts +479 -0
- package/test/fleet-subscribe-union-e2e.test.ts +123 -0
- package/test/fleet-subscribe-union.test.ts +85 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
- package/test/fleet-tree-aggregator.test.ts +215 -0
- package/test/frontdesk-strategy.test.ts +326 -0
- package/test/headless-describe.test.ts +182 -0
- package/test/headless-smoke.test.ts +190 -0
- package/test/logging-adapter.test.ts +87 -0
- package/test/mcpl-admin-module.test.ts +213 -0
- package/test/mcpl-agent-overlay.test.ts +126 -0
- package/test/mock-headless-child.ts +133 -0
- package/test/recipe-cache-ttl.test.ts +37 -0
- package/test/recipe-env.test.ts +157 -0
- package/test/recipe-path-resolution.test.ts +170 -0
- package/test/subagent-async-timeout.test.ts +381 -0
- package/test/subagent-fork-materialise.test.ts +241 -0
- package/test/subagent-peek-scoping.test.ts +180 -0
- package/test/subagent-peek-zombie.test.ts +148 -0
- package/test/subagent-reaper-in-flight.test.ts +229 -0
- package/test/subscription-gc-module.test.ts +136 -0
- package/test/warmup-handoff.test.ts +150 -0
- package/test/web-ui-bind.test.ts +51 -0
- package/test/web-ui-module.test.ts +246 -0
- package/test/web-ui-protocol.test.ts +0 -0
- package/tsconfig.json +14 -0
- package/web/bun.lock +357 -0
- package/web/index.html +12 -0
- package/web/package.json +24 -0
- package/web/src/App.tsx +1931 -0
- package/web/src/Context.tsx +158 -0
- package/web/src/ContextDocument.tsx +150 -0
- package/web/src/Files.tsx +271 -0
- package/web/src/Lessons.tsx +164 -0
- package/web/src/Mcpl.tsx +310 -0
- package/web/src/Stream.tsx +119 -0
- package/web/src/TreeSidebar.tsx +283 -0
- package/web/src/Usage.tsx +182 -0
- package/web/src/main.tsx +7 -0
- package/web/src/styles.css +26 -0
- package/web/src/tree.ts +268 -0
- package/web/src/wire.ts +120 -0
- package/web/tsconfig.json +21 -0
- 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
|
+
}
|