* chore: remove legacy orchestrator, trading and one-off script leftovers The repository still carried the pre-evOo prototype: a Python orchestrator, the old HTML "evoo-platform" cockpit (whose test ran in the root unit suite), an apex-trading package, the hot-reload deploy.sh for it, a permissions manifest, an architecture note with local file links, agent handoff notes, a Steve Jobs persona template and about ninety one-off scripts that probe a developer database with hard-coded local URLs. None of it is used by the product, CI or the real deploy in ops/evo-deploy. Delete those files, drop the evoo-platform test glob from the root vitest config, the Cockpit section from .env.example and the dead biome and gitignore entries, and update the docs that listed the removed tools. scripts/start-tmux.sh no longer starts the removed sentinel and resolves the checkout from its own location instead of a machine-specific path; scripts/seed-ollama.ts requires DATABASE_URL instead of falling back to a local URL with a username. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * fix(web): drop trading and persona prompts from the conversation shell The web shell still showed leftovers from the trading prototype: composer chips with German trading and "Steve Jobs" prompts, trading suggestion chips under the empty conversation line, a German trading hero for the empty transcript, and a German "data protection" status panel in the sidebar. They suggested actions the product does not have and added persistent chrome. Remove the composer chips and their helper, the empty-state chips and the hero (the empty conversation keeps its existing one-line start hint), the sidebar trading indicator and the data trust panel. The new-bot E2E now asserts the plain empty state; the sidebar spec no longer expects the trading status, and the placeholder Steve Jobs spec with no assertions is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * refactor: remove the trading bot status integration With the sidebar trading indicator gone, the only remaining consumer of the trading bot status was a banner in General settings that reused the same component. evOo is a personal assistant; a status proxy and poller for one external trading service, configured through service-specific TRADING_BOT_URL and VITE_TRADING_BOT_URL variables, does not belong in the core product. Remove the settings banner and its prop plumbing, the web poller, the indicator component, the /api/trading-bot/status proxy, the shared core contract, the env loading and both variables from .env.example, together with their tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * chore: remove one-off database scripts and a stale review note packages/db carried four hand-run scripts outside the package build that query or seed a developer database with a hard-coded connection string, one of them for a specific account. docs/VOICE-FIRST-REVIEW.md was a one-time verification note for a workflow that no longer exists and named private machines and local paths. Nothing references them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * chore: replace machine-specific paths and addresses with placeholders ORIGIN.md pinned the intended checkout to an absolute path under a personal home directory, docs/LOCAL-SETUP.md listed folders on one developer's machine, and two tests used what looks like a real tailnet address. Use a home-relative path, drop the machine-specific folder list, and use 100.100.100.100 in the tests; it is still inside 100.64.0.0/10, so both tests check the same thing as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * test(web): drop the trading status from the shell contrast test main's contrast test now waits for the sidebar trading status before measuring; this branch removes that status. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
372 lines
14 KiB
TypeScript
372 lines
14 KiB
TypeScript
import dns from "node:dns";
|
|
import { fetch as undiciFetch } from "undici";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
assertSafeRemoteUrl,
|
|
createSafeLookup,
|
|
createSafeRemoteFetch,
|
|
limitRemoteMcpPayload,
|
|
} from "./remote-mcp.js";
|
|
|
|
const publicResolver = async () => [{ address: "203.0.113.10", family: 4 as const }];
|
|
|
|
describe("remote MCP URL policy", () => {
|
|
it("accepts public HTTPS endpoints", async () => {
|
|
await expect(
|
|
assertSafeRemoteUrl("https://connectors.example.test/mcp", publicResolver),
|
|
).resolves.toEqual(new URL("https://connectors.example.test/mcp"));
|
|
});
|
|
|
|
it("accepts hosts that resolve to a public IPv6 address", async () => {
|
|
await expect(
|
|
assertSafeRemoteUrl("https://connectors.example.test/mcp", async () => [
|
|
{ address: "2606:4700:4700::1111", family: 6 as const },
|
|
]),
|
|
).resolves.toEqual(new URL("https://connectors.example.test/mcp"));
|
|
});
|
|
|
|
it.each([
|
|
"http://connectors.example.test/mcp",
|
|
"https://localhost/mcp",
|
|
"https://127.0.0.1/mcp",
|
|
"https://169.254.169.254/latest/meta-data",
|
|
"https://user:password@connectors.example.test/mcp",
|
|
"https://[::1]/mcp",
|
|
"https://[::ffff:127.0.0.1]/mcp",
|
|
])("rejects unsafe endpoint %s", async (endpoint) => {
|
|
await expect(assertSafeRemoteUrl(endpoint, publicResolver)).rejects.toThrow();
|
|
});
|
|
|
|
it("rejects a hostname that resolves privately", async () => {
|
|
await expect(
|
|
assertSafeRemoteUrl("https://connectors.example.test/mcp", async () => [
|
|
{ address: "10.1.2.3", family: 4 as const },
|
|
]),
|
|
).rejects.toThrow("private address");
|
|
});
|
|
|
|
it("allows Tailscale MagicDNS hosts that resolve to CGNAT addresses", async () => {
|
|
const magicDns = "https://box.tail12345.ts.net/openapi.json";
|
|
await expect(
|
|
assertSafeRemoteUrl(magicDns, async () => [{ address: "100.64.1.2", family: 4 as const }]),
|
|
).resolves.toEqual(new URL(magicDns));
|
|
|
|
const safeLookup = createSafeLookup(async () => [{ address: "100.100.100.100", family: 4 }]);
|
|
const result = await new Promise<{ address: string; family?: number }>((resolve, reject) => {
|
|
safeLookup("box.tail12345.ts.net", { family: 0, all: false }, (error, address, family) => {
|
|
if (error) reject(error);
|
|
else resolve({ address: String(address), family });
|
|
});
|
|
});
|
|
expect(result).toEqual({ address: "100.100.100.100", family: 4 });
|
|
});
|
|
|
|
it("allows the IPv6 half of a MagicDNS answer", async () => {
|
|
// MagicDNS answers with the node's fd7a:115c:a1e0::/48 ULA alongside its
|
|
// CGNAT address, so rejecting the ULA rejects every tailnet host.
|
|
const magicDns = "https://box.tail12345.ts.net/mcp";
|
|
const addresses = [
|
|
{ address: "fd7a:115c:a1e0:ab12:4843:cd96:6265:6667", family: 6 as const },
|
|
{ address: "100.64.1.2", family: 4 as const },
|
|
];
|
|
await expect(assertSafeRemoteUrl(magicDns, async () => addresses)).resolves.toEqual(
|
|
new URL(magicDns),
|
|
);
|
|
|
|
const safeLookup = createSafeLookup(async () => addresses);
|
|
const resolved = await new Promise<unknown>((resolve, reject) => {
|
|
safeLookup("box.tail12345.ts.net", { family: 0, all: true }, (error, entries) => {
|
|
if (error) reject(error);
|
|
else resolve(entries);
|
|
});
|
|
});
|
|
expect(resolved).toEqual(addresses);
|
|
});
|
|
|
|
it("rejects non-Tailscale IPv6 private ranges for MagicDNS hosts", async () => {
|
|
await expect(
|
|
assertSafeRemoteUrl("https://box.tail12345.ts.net/mcp", async () => [
|
|
{ address: "fd00:1234::1", family: 6 as const },
|
|
]),
|
|
).rejects.toThrow("private address");
|
|
});
|
|
|
|
it("still rejects raw Tailscale CGNAT IP literals", async () => {
|
|
await expect(
|
|
assertSafeRemoteUrl("https://100.64.1.2/openapi.json", publicResolver),
|
|
).rejects.toThrow(/private host/i);
|
|
});
|
|
|
|
it("rejects MagicDNS hosts that resolve outside Tailscale CGNAT", async () => {
|
|
await expect(
|
|
assertSafeRemoteUrl("https://box.tail12345.ts.net/openapi.json", async () => [
|
|
{ address: "127.0.0.1", family: 4 as const },
|
|
]),
|
|
).rejects.toThrow("private address");
|
|
await expect(
|
|
assertSafeRemoteUrl("https://box.tail12345.ts.net/openapi.json", async () => [
|
|
{ address: "10.1.2.3", family: 4 as const },
|
|
]),
|
|
).rejects.toThrow("private address");
|
|
await expect(
|
|
assertSafeRemoteUrl("https://box.tail12345.ts.net/openapi.json", async () => [
|
|
{ address: "169.254.169.254", family: 4 as const },
|
|
]),
|
|
).rejects.toThrow("private address");
|
|
await expect(
|
|
assertSafeRemoteUrl("https://box.tail12345.ts.net/openapi.json", async () => [
|
|
{ address: "100.100.100.200", family: 4 as const },
|
|
]),
|
|
).rejects.toThrow("private address");
|
|
});
|
|
|
|
it("rejects private addresses in the lookup used by the network connection", async () => {
|
|
const safeLookup = createSafeLookup(async () => [{ address: "10.1.2.3", family: 4 }]);
|
|
const error = await new Promise<Error | null>((resolve) => {
|
|
safeLookup("connectors.example.test", { family: 0, all: false }, (lookupError) => {
|
|
resolve(lookupError);
|
|
});
|
|
});
|
|
expect(error).toMatchObject({ message: "Connector URL resolves to a private address" });
|
|
});
|
|
|
|
it("returns the validated address directly to the network connection", async () => {
|
|
const safeLookup = createSafeLookup(publicResolver);
|
|
const result = await new Promise<{ address: string; family?: number }>((resolve, reject) => {
|
|
safeLookup("connectors.example.test", { family: 0, all: false }, (error, address, family) => {
|
|
if (error) reject(error);
|
|
else resolve({ address: String(address), family });
|
|
});
|
|
});
|
|
expect(result).toEqual({ address: "203.0.113.10", family: 4 });
|
|
});
|
|
|
|
it("reports why a connection failed instead of a bare fetch failure", async () => {
|
|
const cause = Object.assign(new Error("connect EHOSTUNREACH 100.64.1.2:443"), {
|
|
code: "EHOSTUNREACH",
|
|
});
|
|
const safeFetch = createSafeRemoteFetch(async () => {
|
|
throw new TypeError("fetch failed", { cause });
|
|
}, publicResolver);
|
|
try {
|
|
await expect(safeFetch("https://connectors.example.test/mcp")).rejects.toThrow(
|
|
"Could not reach connectors.example.test: connect EHOSTUNREACH 100.64.1.2:443",
|
|
);
|
|
} finally {
|
|
await safeFetch.close();
|
|
}
|
|
});
|
|
|
|
it("unwraps the per-address errors undici reports for dual-stack hosts", async () => {
|
|
const safeFetch = createSafeRemoteFetch(async () => {
|
|
throw new TypeError("fetch failed", {
|
|
cause: new AggregateError(
|
|
[new TypeError("fetch failed"), new Error("connect ECONNREFUSED 100.64.1.2:443")],
|
|
"",
|
|
),
|
|
});
|
|
}, publicResolver);
|
|
try {
|
|
await expect(safeFetch("https://connectors.example.test/mcp")).rejects.toThrow(
|
|
"connect ECONNREFUSED 100.64.1.2:443",
|
|
);
|
|
} finally {
|
|
await safeFetch.close();
|
|
}
|
|
});
|
|
|
|
it("drives the guarded dispatcher with a fetch from the same undici", async () => {
|
|
// A fetch from a different undici than the Agent fails at dispatch with
|
|
// "invalid onRequestStart method" before the lookup runs. Failing inside
|
|
// the lookup proves the request reached the guarded Agent. Node's fetch
|
|
// is a different major, so omit, builtin, and a captured builtin must
|
|
// all use the package fetch that matches the Agent.
|
|
expect(undiciFetch).not.toBe(globalThis.fetch);
|
|
|
|
const capturedNodeFetch = globalThis.fetch;
|
|
let resolutions = 0;
|
|
const resolve = async () => {
|
|
resolutions += 1;
|
|
if (resolutions > 1) throw new Error("lookup reached");
|
|
return [{ address: "203.0.113.10", family: 4 as const }];
|
|
};
|
|
for (const injected of [undefined, globalThis.fetch, capturedNodeFetch] as const) {
|
|
resolutions = 0;
|
|
const safeFetch = createSafeRemoteFetch(injected, resolve);
|
|
try {
|
|
await expect(safeFetch("https://connectors.example.test/mcp")).rejects.toThrow(
|
|
"Could not reach connectors.example.test: lookup reached",
|
|
);
|
|
} finally {
|
|
await safeFetch.close();
|
|
}
|
|
}
|
|
});
|
|
|
|
it("still pins lookup for a captured Node fetch after globalThis.fetch changes", async () => {
|
|
const captured = globalThis.fetch;
|
|
const previous = globalThis.fetch;
|
|
globalThis.fetch = (async () => new Response(null, { status: 204 })) as typeof globalThis.fetch;
|
|
let resolutions = 0;
|
|
const resolve = async () => {
|
|
resolutions += 1;
|
|
if (resolutions > 1) throw new Error("lookup reached");
|
|
return [{ address: "203.0.113.10", family: 4 as const }];
|
|
};
|
|
try {
|
|
const safeFetch = createSafeRemoteFetch(captured, resolve);
|
|
try {
|
|
await expect(safeFetch("https://connectors.example.test/mcp")).rejects.toThrow(
|
|
"Could not reach connectors.example.test: lookup reached",
|
|
);
|
|
} finally {
|
|
await safeFetch.close();
|
|
}
|
|
} finally {
|
|
globalThis.fetch = previous;
|
|
}
|
|
});
|
|
|
|
it("does not pass the package Agent to a wrapper around Node's fetch", async () => {
|
|
// Wrappers are not === Node's fetch. Passing them the package Agent throws
|
|
// invalid onRequestStart before lookup. Call them without dispatcher.
|
|
let leakedDispatcher = false;
|
|
let href: string | undefined;
|
|
const wrapped: typeof globalThis.fetch = (input, init) => {
|
|
leakedDispatcher = Boolean(init && "dispatcher" in init);
|
|
href = String(input);
|
|
return globalThis.fetch(input, init);
|
|
};
|
|
const previous = globalThis.fetch;
|
|
globalThis.fetch = (async () => {
|
|
throw new Error("stub fetch");
|
|
}) as typeof globalThis.fetch;
|
|
const safeFetch = createSafeRemoteFetch(wrapped, publicResolver);
|
|
try {
|
|
await expect(safeFetch("https://connectors.example.test/mcp")).rejects.toThrow("stub fetch");
|
|
expect(leakedDispatcher).toBe(false);
|
|
expect(href).toBe("https://connectors.example.test/mcp");
|
|
} finally {
|
|
globalThis.fetch = previous;
|
|
await safeFetch.close();
|
|
}
|
|
});
|
|
|
|
it("does not pass the package Agent to a mock fetch", async () => {
|
|
let leakedDispatcher = false;
|
|
const mock: typeof globalThis.fetch = async (_input, init) => {
|
|
leakedDispatcher = Boolean(init && "dispatcher" in init);
|
|
return new Response(null, { status: 204 });
|
|
};
|
|
const safeFetch = createSafeRemoteFetch(mock, publicResolver);
|
|
try {
|
|
await expect(safeFetch("https://connectors.example.test/mcp")).resolves.toMatchObject({
|
|
status: 204,
|
|
});
|
|
expect(leakedDispatcher).toBe(false);
|
|
} finally {
|
|
await safeFetch.close();
|
|
}
|
|
});
|
|
|
|
it("pins an injected fetch to the validated address so DNS cannot rebind", async () => {
|
|
const seen: {
|
|
href?: string;
|
|
host?: string | null;
|
|
leakedDispatcher?: boolean;
|
|
lookup?: { address: string; family?: number };
|
|
} = {};
|
|
const injected: typeof globalThis.fetch = async (input, init) => {
|
|
seen.href = String(input);
|
|
seen.host = new Headers(init?.headers).get("host");
|
|
seen.leakedDispatcher = Boolean(init && "dispatcher" in init);
|
|
const hostname = new URL(String(input)).hostname.replace(/^\[|\]$/g, "");
|
|
if (hostname === "127.0.0.1" || hostname === "203.0.113.10") {
|
|
throw new Error(`injected fetch reached rebound host ${hostname}`);
|
|
}
|
|
seen.lookup = await new Promise<{ address: string; family?: number }>((resolve, reject) => {
|
|
dns.lookup(hostname, { family: 4 }, (error, address, family) => {
|
|
if (error) reject(error);
|
|
else resolve({ address: String(address), family });
|
|
});
|
|
});
|
|
return new Response(null, { status: 204 });
|
|
};
|
|
const safeFetch = createSafeRemoteFetch(injected, publicResolver);
|
|
try {
|
|
await expect(safeFetch("https://connectors.example.test/mcp")).resolves.toMatchObject({
|
|
status: 204,
|
|
});
|
|
expect(seen.leakedDispatcher).toBe(false);
|
|
expect(seen.href).toBe("https://connectors.example.test/mcp");
|
|
expect(seen.host).toBe("connectors.example.test");
|
|
expect(seen.lookup).toEqual({ address: "203.0.113.10", family: 4 });
|
|
} finally {
|
|
await safeFetch.close();
|
|
}
|
|
});
|
|
|
|
it("pins an injected fetch to a validated IPv6 address", async () => {
|
|
const seen: {
|
|
href?: string;
|
|
host?: string | null;
|
|
lookup?: { address: string; family?: number };
|
|
} = {};
|
|
const injected: typeof globalThis.fetch = async (input, init) => {
|
|
seen.href = String(input);
|
|
seen.host = new Headers(init?.headers).get("host");
|
|
const hostname = new URL(String(input)).hostname.replace(/^\[|\]$/g, "");
|
|
seen.lookup = await new Promise<{ address: string; family?: number }>((resolve, reject) => {
|
|
dns.lookup(hostname, { family: 6 }, (error, address, family) => {
|
|
if (error) reject(error);
|
|
else resolve({ address: String(address), family });
|
|
});
|
|
});
|
|
return new Response(null, { status: 204 });
|
|
};
|
|
const safeFetch = createSafeRemoteFetch(injected, async () => [
|
|
{ address: "2606:4700:4700::1111", family: 6 as const },
|
|
]);
|
|
try {
|
|
await expect(safeFetch("https://connectors.example.test/mcp")).resolves.toMatchObject({
|
|
status: 204,
|
|
});
|
|
expect(seen.href).toBe("https://connectors.example.test/mcp");
|
|
expect(seen.host).toBe("connectors.example.test");
|
|
expect(seen.lookup).toEqual({ address: "2606:4700:4700::1111", family: 6 });
|
|
} finally {
|
|
await safeFetch.close();
|
|
}
|
|
});
|
|
|
|
it("rejects Request inputs instead of silently dropping their method and body", async () => {
|
|
const safeFetch = createSafeRemoteFetch(
|
|
async () => new Response(null, { status: 204 }),
|
|
publicResolver,
|
|
);
|
|
try {
|
|
await expect(
|
|
safeFetch(
|
|
new Request("https://connectors.example.test/mcp", {
|
|
method: "POST",
|
|
body: "payload",
|
|
}),
|
|
),
|
|
).rejects.toThrow("requires a URL");
|
|
} finally {
|
|
await safeFetch.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("remote MCP result limits", () => {
|
|
it("applies the result budget in UTF-8 bytes instead of JavaScript characters", () => {
|
|
const value = { content: "界".repeat(400_000) };
|
|
const limited = limitRemoteMcpPayload(value) as { truncated: boolean; content: string };
|
|
|
|
expect(limited.truncated).toBe(true);
|
|
expect(Buffer.byteLength(limited.content, "utf8")).toBeLessThanOrEqual(1_000_000);
|
|
expect(limited.content).not.toContain("\uFFFD");
|
|
});
|
|
});
|