Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions bench/contradiction_longitudinal.py
Original file line number Diff line number Diff line change
Expand Up @@ -812,9 +812,9 @@ def write_outputs(

env_with_duration = {**env, "wall_clock_duration_seconds": round(duration_seconds, 2)}
json_blob = {"env": env_with_duration, "summary": summary}
json_path.write_text(json.dumps(json_blob, indent=2, default=str))
json_path.write_text(json.dumps(json_blob, indent=2, default=str), encoding="utf-8")

with csv_path.open("w", newline="") as fh:
with csv_path.open("w", newline="", encoding="utf-8") as fh:
w = csv.writer(fh)
w.writerow([
"probe_id", "seed", "n_slice", "condition", "topic",
Expand Down Expand Up @@ -897,7 +897,7 @@ def write_outputs(
"- **Metric B-contract (s4_contradiction hint OR anti_hits ≥80%)** tests what the system actually promises (REQUIREMENTS.md MEM-08, MCP-01 dual-route). Cosine cannot do either; pipeline either signals contradictions or it doesn't.",
"",
]
md_path.write_text("\n".join(lines))
md_path.write_text("\n".join(lines), encoding="utf-8")

print(f"[bench] Wrote: {md_path}")
print(f"[bench] Wrote: {json_path}")
Expand Down
15 changes: 13 additions & 2 deletions mcp-wrapper/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as crypto from "node:crypto";
import * as net from "node:net";
import {
type ConnectTarget,
authenticateConnection,
createDaemonConnection,
daemonUnreachableHint,
getDaemonConnectTarget,
Expand Down Expand Up @@ -74,13 +75,15 @@ export class PythonCoreBridge {
throw new DaemonUnreachableError(daemonUnreachableHint());
}

let sock: net.Socket;
let sock: net.Socket | null = null;
try {
sock = await this.connectWithTimeout(
target,
SOCKET_CONNECT_TIMEOUT_MS,
);
authenticateConnection(sock, target);
} catch (e) {
try { sock?.destroy(); } catch { }
throw new DaemonUnreachableError(daemonUnreachableHint());
}
this.sock = sock;
Expand Down Expand Up @@ -198,10 +201,12 @@ export class PythonCoreBridge {
if (target === null) {
return;
}
this.sock = await this.connectWithTimeout(
const sock = await this.connectWithTimeout(
target,
SOCKET_CONNECT_TIMEOUT_MS,
);
authenticateConnection(sock, target);
this.sock = sock;
this.attachSocketHandlers();
} catch {
} finally {
Expand Down Expand Up @@ -279,6 +284,12 @@ export function emitSessionOpen(sessionId: string): Promise<void> {
return;
}
const sock = createDaemonConnection(target, () => {
try {
authenticateConnection(sock, target);
} catch {
try { sock.destroy(); } catch { }
return;
}
const msg =
JSON.stringify({
type: "session_open",
Expand Down
53 changes: 53 additions & 0 deletions mcp-wrapper/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,28 @@ export function readDaemonPort(): number | null {
}
}

/**
* Windows-only auth token file, mirroring `_ipc.TOKEN_FILE` on the Python
* side. The daemon requires every TCP client to send this token as the
* first line on a fresh connection (see `_make_authenticated_handler` /
* `_send_token_sync` in `_ipc.py`) — a plain connect is not enough.
* IAI_DAEMON_TOKEN_PATH overrides for tests.
*/
export function daemonTokenPath(): string {
const env = process.env.IAI_DAEMON_TOKEN_PATH;
if (env) return env;
return path.join(daemonBaseDir(), ".daemon.token");
}

export function readDaemonToken(): string | null {
try {
const txt = fs.readFileSync(daemonTokenPath(), "utf-8").trim();
return txt.length > 0 ? txt : null;
} catch {
return null;
}
}

/**
* Resolve the daemon IPC endpoint.
* POSIX -> Unix-domain socket path (string)
Expand All @@ -50,6 +72,16 @@ export function readDaemonPort(): number | null {
export function getDaemonConnectTarget(): ConnectTarget | null {
const env = process.env.IAI_DAEMON_SOCKET_PATH;
if (env) return env;
// Test-only escape hatch so the TCP+auth-token path can be exercised
// without depending on the real process.platform (IS_WINDOWS is a
// module-load-time constant and can't be swapped mid-test-run).
const tcpPort = process.env.IAI_DAEMON_TCP_PORT;
if (tcpPort) {
const port = Number.parseInt(tcpPort, 10);
if (Number.isFinite(port) && port > 0) {
return { host: process.env.IAI_DAEMON_TCP_HOST ?? "127.0.0.1", port };
}
}
if (IS_WINDOWS) {
const port = readDaemonPort();
return port === null ? null : { host: "127.0.0.1", port };
Expand Down Expand Up @@ -95,3 +127,24 @@ export function createDaemonConnection(
? net.createConnection(target.port, target.host, connectListener)
: net.createConnection(target.port, target.host);
}

/**
* Perform the auth-token handshake a TCP connection requires before the
* daemon will process any RPC traffic on it. Unix-socket targets (POSIX)
* need no handshake and are a no-op here.
*
* Must be called synchronously right after connect, before anything else
* is written to the socket — the daemon's `_make_authenticated_handler`
* requires the token to be the very first line, and closes the connection
* outright (no error response) if it isn't.
*/
export function authenticateConnection(sock: net.Socket, target: ConnectTarget): void {
if (typeof target === "string") return;
const token = readDaemonToken();
if (token === null) {
throw new Error(
`Daemon auth token not found: ${daemonTokenPath()} missing. The daemon may need a restart to regenerate it.`,
);
}
sock.write(token + "\n");
}
144 changes: 143 additions & 1 deletion mcp-wrapper/test/bridge.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@

import { describe, it, afterEach } from "node:test";
import { strict as assert } from "node:assert";
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import * as net from "node:net";
Expand Down Expand Up @@ -97,6 +97,9 @@ afterEach(async () => {
tmpDirs = [];
delete process.env.IAI_DAEMON_SOCKET_PATH;
delete process.env.IAI_MCP_RECONNECT_TEST_DELAY_MS;
delete process.env.IAI_DAEMON_TCP_PORT;
delete process.env.IAI_DAEMON_TCP_HOST;
delete process.env.IAI_DAEMON_TOKEN_PATH;
});

async function setup(): Promise<{ daemon: MockDaemon; bridge: PythonCoreBridge }> {
Expand Down Expand Up @@ -419,3 +422,142 @@ describe("PythonCoreBridge: rapid connection flaps", () => {
assert.equal(bridge.isConnected(), true);
});
});


// Simulates the Windows daemon's `_make_authenticated_handler`: the first
// line on every connection must equal `expectedToken`, or the connection is
// closed immediately with nothing written back — mirroring _ipc.py exactly,
// including the "silently closed, no error frame" behavior that produced the
// `daemon_unreachable: socket closed` symptom this test guards against.
class MockAuthDaemon {
private server: net.Server;
private connections: net.Socket[] = [];
private readonly expectedToken: string;
public requestHandler: (req: { id: number; method: string; params: unknown }) => unknown;

constructor(expectedToken: string) {
this.expectedToken = expectedToken;
this.requestHandler = (req) => ({ ok: true, method: req.method });
this.server = net.createServer((conn) => {
this.connections.push(conn);
let authenticated = false;
let buf = "";
conn.on("data", (chunk) => {
buf += chunk.toString("utf-8");
let nl: number;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!authenticated) {
authenticated = true;
if (line !== this.expectedToken) {
conn.destroy();
return;
}
continue;
}
if (!line) continue;
try {
const req = JSON.parse(line);
const result = this.requestHandler(req);
conn.write(JSON.stringify({ jsonrpc: "2.0", id: req.id, result }) + "\n");
} catch (e) {
const req = JSON.parse(line);
conn.write(
JSON.stringify({ jsonrpc: "2.0", id: req.id, error: { code: -32603, message: String(e) } }) + "\n",
);
}
}
});
});
}

async listen(): Promise<number> {
return new Promise((resolve) => {
this.server.listen(0, "127.0.0.1", () => {
resolve((this.server.address() as net.AddressInfo).port);
});
});
}

async close(): Promise<void> {
for (const c of this.connections) { try { c.destroy(); } catch { } }
this.connections = [];
return new Promise((resolve) => { this.server.close(() => resolve()); });
}
}

describe("PythonCoreBridge: TCP auth-token handshake (Windows transport)", () => {
it("authenticates with the token before any RPC traffic and completes calls normally", async () => {
const dir = await makeTmpDir();
tmpDirs.push(dir);
const tokenPath = join(dir, ".daemon.token");
const token = "test-token-abc123";
await writeFile(tokenPath, token, "utf-8");

const daemon = new MockAuthDaemon(token);
const port = await daemon.listen();

process.env.IAI_DAEMON_TCP_PORT = String(port);
process.env.IAI_DAEMON_TOKEN_PATH = tokenPath;
const bridge = new PythonCoreBridge();
bridges.push(bridge);

await bridge.start();
assert.equal(bridge.isConnected(), true);

const res = await bridge.call<{ echo: string }>("ping");
assert.equal(res.method, "ping");

await daemon.close();
});

it("fails as daemon_unreachable when the token file is missing", async () => {
const dir = await makeTmpDir();
tmpDirs.push(dir);
const missingTokenPath = join(dir, ".daemon.token"); // never written

const daemon = new MockAuthDaemon("irrelevant-since-no-token-sent");
const port = await daemon.listen();

process.env.IAI_DAEMON_TCP_PORT = String(port);
process.env.IAI_DAEMON_TOKEN_PATH = missingTokenPath;
const bridge = new PythonCoreBridge();
bridges.push(bridge);

await assert.rejects(
() => bridge.start(),
(err: Error) => err instanceof DaemonUnreachableError,
);
assert.equal(bridge.isConnected(), false);

await daemon.close();
});

it("gets disconnected by the daemon when the token is wrong (pre-fix regression case)", async () => {
const dir = await makeTmpDir();
tmpDirs.push(dir);
const tokenPath = join(dir, ".daemon.token");
await writeFile(tokenPath, "wrong-token", "utf-8");

const daemon = new MockAuthDaemon("actual-expected-token");
const port = await daemon.listen();

process.env.IAI_DAEMON_TCP_PORT = String(port);
process.env.IAI_DAEMON_TOKEN_PATH = tokenPath;
const bridge = new PythonCoreBridge();
bridges.push(bridge);

// The mismatched-token socket is accepted at the TCP level and only
// closed after the daemon reads the (wrong) first line, so start()
// itself resolves; the failure shows up as the daemon severing the
// connection right after, exactly like the bug this test targets.
await bridge.start();
await assert.rejects(
() => bridge.call("ping"),
(err: Error) => err.message.includes("daemon_unreachable"),
);

await daemon.close();
});
});
43 changes: 43 additions & 0 deletions src/iai_mcp/_ipc.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@

_TOKEN_BYTES = 32 # 256-bit random token → 64 hex chars on the wire

# The auth token the *current process* generated for its listening server, held
# in memory for the daemon's whole lifetime. Populated by _generate_token (only
# the daemon calls that). Lets the daemon re-assert a vanished token file via
# reassert_token_if_missing() without re-reading it from disk. Stays None in
# client processes, which must never re-create the daemon's token.
_CURRENT_TOKEN: str | None = None


# ---------------------------------------------------------------------------
# Port file helpers (Windows only)
Expand Down Expand Up @@ -105,14 +112,45 @@ def _token_file_path() -> Path:

def _generate_token() -> str:
"""Generate a fresh 32-byte random token and persist it to the token file."""
global _CURRENT_TOKEN
token = secrets.token_hex(_TOKEN_BYTES)
path = _token_file_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(token, encoding="utf-8")
_restrict_token_file(path)
_CURRENT_TOKEN = token
return token


def reassert_token_if_missing() -> bool:
"""Re-write the Windows auth-token file from the in-memory token if it has
gone missing while the daemon is running.

The token file is a single point of failure: it is written once at startup,
but if it later disappears for any reason (external cleanup, AV quarantine,
a stale-file sweep) the TCP port stays up while EVERY new client fails the
auth handshake — the daemon looks alive but new sessions and ``daemon
status`` all report "not running". The daemon still holds the token in
memory, so we can restore the file cheaply. Called from the daemon's
periodic tick.

Returns True iff it just re-created the file. No-op (returns False) on POSIX,
in client processes (``_CURRENT_TOKEN is None``), or when the file is present.
"""
if not IS_WINDOWS or _CURRENT_TOKEN is None:
return False
path = _token_file_path()
if path.exists():
return False
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(_CURRENT_TOKEN, encoding="utf-8")
_restrict_token_file(path)
except OSError:
return False
return True


def _read_token() -> str | None:
try:
return _token_file_path().read_text(encoding="utf-8").strip()
Expand All @@ -121,6 +159,11 @@ def _read_token() -> str | None:


def _remove_token_file() -> None:
# Clear the in-memory copy first so reassert_token_if_missing() cannot
# resurrect a token the daemon is deliberately tearing down (clean shutdown,
# in-process restart, tests).
global _CURRENT_TOKEN
_CURRENT_TOKEN = None
try:
_token_file_path().unlink()
except (FileNotFoundError, OSError):
Expand Down
Loading
Loading