From 09accf4a64b981c9bc10c21d5b6adbfb6cf13606 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 14:34:57 +0000 Subject: [PATCH 1/5] test: lint rule banning disabled TLS verification in tests (#104) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UHcZfMyTBtKc72KVYQw4rb --- .../src/__tests__/lint-boundaries.test.ts | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/appduct/src/__tests__/lint-boundaries.test.ts b/packages/appduct/src/__tests__/lint-boundaries.test.ts index 53a9c70..b920e63 100644 --- a/packages/appduct/src/__tests__/lint-boundaries.test.ts +++ b/packages/appduct/src/__tests__/lint-boundaries.test.ts @@ -2,7 +2,8 @@ * The architecture rules in AGENTS.md are enforced by the root ESLint config. These tests lint * small snippets under hypothetical paths, so they pin the rules themselves rather than the * current state of the tree: a module's index.ts is its only import surface, Node I/O is reached - * only from adapters and composition roots, and tests never module-mock. + * only from adapters and composition roots, tests never module-mock, and tests never switch off + * TLS verification. */ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -86,6 +87,49 @@ describe("lint: tests", () => { }); }); +describe("lint: TLS verification in tests", () => { + test("a test switching off TLS verification process-wide fails", async () => { + const rules = await lint("packages/appduct/src/__tests__/probe.integration.test.ts", 'process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";\n'); + expect(rules).toContain("appduct/no-tls-bypass"); + }); + + test("the bracketed form of the same assignment fails", async () => { + const rules = await lint("packages/appduct/src/__tests__/probe.integration.test.ts", 'process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0";\n'); + expect(rules).toContain("appduct/no-tls-bypass"); + }); + + test("handing a child process an environment without TLS verification fails", async () => { + const rules = await lint("packages/appduct/src/__tests__/probe.e2e.test.ts", 'export const env = { ...process.env, NODE_TLS_REJECT_UNAUTHORIZED: "0" };\n'); + expect(rules).toContain("appduct/no-tls-bypass"); + }); + + test("a test client passing rejectUnauthorized: false fails", async () => { + const rules = await lint( + "packages/appduct/src/__tests__/probe.integration.test.ts", + 'import WebSocket from "ws";\nexport const ws = new WebSocket("wss://127.0.0.1:1", { rejectUnauthorized: false });\n', + ); + expect(rules).toContain("appduct/no-tls-bypass"); + }); + + test("a test helper outside a *.test.ts file is covered too", async () => { + const rules = await lint("packages/appduct/src/__tests__/e2e/probe-helper.ts", 'import { connect } from "node:tls";\nexport const s = connect({ port: 1, rejectUnauthorized: false });\n'); + expect(rules).toContain("appduct/no-tls-bypass"); + }); + + test("a test trusting the daemon's own certificate passes", async () => { + const rules = await lint( + "packages/appduct/src/__tests__/probe.integration.test.ts", + 'import WebSocket from "ws";\ndeclare const daemon: { tls: { current(): { certPem: string } } };\nexport const ws = new WebSocket("wss://127.0.0.1:1", { ca: daemon.tls.current().certPem });\n', + ); + expect(rules).toEqual([]); + }); + + test("source outside tests is not linted by it: on a TLS server the option is about client certificates", async () => { + const rules = await lint("packages/appduct/src/daemon/node-probe-server.ts", 'import { createServer } from "node:https";\nexport const s = createServer({ requestCert: false, rejectUnauthorized: false });\n'); + expect(rules).toEqual([]); + }); +}); + /** * The burn-down lists exempt files that predate the rules. Each entry must still be a genuine * violator: a file converted to a port but left on the list would silently re-admit the exact @@ -97,6 +141,7 @@ describe("lint: burn-down lists", () => { LEGACY_NODE_IO: string[]; LEGACY_VI_MOCK: string[]; LEGACY_MODULE_BOUNDARY: string[]; + LEGACY_TLS_BYPASS: string[]; NODE_IO_RESTRICTION: Linter.RuleEntry; VI_MOCK_RESTRICTION: Linter.RuleEntry; }; @@ -124,4 +169,9 @@ describe("lint: burn-down lists", () => { const { LEGACY_MODULE_BOUNDARY } = await loadConfig(); expect(await violators(LEGACY_MODULE_BOUNDARY, { "appduct/module-boundary": "error" }, "appduct/module-boundary")).toEqual(LEGACY_MODULE_BOUNDARY); }); + + test("every LEGACY_TLS_BYPASS entry still switches off TLS verification", async () => { + const { LEGACY_TLS_BYPASS } = await loadConfig(); + expect(await violators(LEGACY_TLS_BYPASS, { "appduct/no-tls-bypass": "error" }, "appduct/no-tls-bypass")).toEqual(LEGACY_TLS_BYPASS); + }); }); From c72073b10b99c5e66f8b66ea935e8b611408285c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 14:39:17 +0000 Subject: [PATCH 2/5] chore(lint): ban disabling TLS verification in tests (#104) Adds appduct/no-tls-bypass for test files. In-process daemon tests now trust the daemon's certificate with ca: daemon.tls.current().certPem; tests whose daemon runs as a subprocess go on LEGACY_TLS_BYPASS. 6 failing -> 0 failing Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UHcZfMyTBtKc72KVYQw4rb --- eslint.config.mjs | 42 ++++- .../__tests__/mcp-server.integration.test.ts | 91 +++++----- .../policy-and-audit.integration.test.ts | 99 +++++------ .../session-engine.integration.test.ts | 59 +++---- .../__tests__/tls-refresh.integration.test.ts | 10 +- .../tool-invocation.integration.test.ts | 167 ++++++++---------- 6 files changed, 241 insertions(+), 227 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 30b0854..1523e2f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -68,6 +68,18 @@ export const LEGACY_MODULE_BOUNDARY = [ "packages/appduct/src/__tests__/link-open.integration.test.ts", ]; +// Tests that switched off TLS verification before the ban. Each talks to a daemon running as a +// CLI subprocess, whose certificate it never sees, so it has no `ca` to pass. Same deal as +// above: remove when converted, never add. +export const LEGACY_TLS_BYPASS = [ + "packages/appduct/src/__tests__/cli-v2.integration.test.ts", + "packages/appduct/src/__tests__/e2e/app-client.ts", + "packages/appduct/src/__tests__/e2e/harness.ts", + "packages/appduct/src/__tests__/e2e/hostility.e2e.test.ts", + "packages/appduct/src/__tests__/events.integration.test.ts", + "packages/appduct/src/__tests__/exit-codes.integration.test.ts", +]; + // Rule 1, modules. A module is a directory under a package's src that has an index.ts; that // file is its only import surface. Modules are discovered at lint time, so a directory becomes // one the moment it gains an index.ts and there is no list to keep in step. @@ -113,6 +125,29 @@ const noNodeIoDynamicImport = { }, }; +// A test that disables TLS verification passes against any certificate, so it cannot catch a +// daemon serving the wrong one. Only tests are linted: on a TLS server, rejectUnauthorized is +// about client certificates and false is the normal setting. +const TLS_MESSAGE = "Trust the daemon's own certificate with `ca: daemon.tls.current().certPem` instead of switching off TLS verification."; +const keyName = (node) => (node.type === "Identifier" ? node.name : node.type === "Literal" ? node.value : undefined); +const noTlsBypass = { + meta: { type: "problem", docs: { description: "no disabling TLS verification in tests" }, schema: [], messages: { bypass: TLS_MESSAGE } }, + create(context) { + const report = (node) => context.report({ node, messageId: "bypass" }); + return { + Property(node) { + const key = node.computed ? undefined : keyName(node.key); + if (key === "NODE_TLS_REJECT_UNAUTHORIZED") report(node); + if (key === "rejectUnauthorized" && node.value.type === "Literal" && node.value.value === false) report(node); + }, + AssignmentExpression(node) { + const target = node.left; + if (target.type === "MemberExpression" && keyName(target.property) === "NODE_TLS_REJECT_UNAUTHORIZED") report(node); + }, + }; + }, +}; + const moduleBoundary = { meta: { type: "problem", @@ -148,7 +183,7 @@ export default [ { files: SOURCE, languageOptions: { parser: tseslint.parser, ecmaVersion: 2024, sourceType: "module" }, - plugins: { appduct: { rules: { "module-boundary": moduleBoundary, "no-node-io-dynamic-import": noNodeIoDynamicImport } } }, + plugins: { appduct: { rules: { "module-boundary": moduleBoundary, "no-node-io-dynamic-import": noNodeIoDynamicImport, "no-tls-bypass": noTlsBypass } } }, }, { files: SOURCE, @@ -165,4 +200,9 @@ export default [ ignores: LEGACY_VI_MOCK, rules: { "no-restricted-properties": VI_MOCK_RESTRICTION }, }, + { + files: TESTS, + ignores: LEGACY_TLS_BYPASS, + rules: { "appduct/no-tls-bypass": "error" }, + }, ]; diff --git a/packages/appduct/src/__tests__/mcp-server.integration.test.ts b/packages/appduct/src/__tests__/mcp-server.integration.test.ts index b498dbb..b11c7b3 100644 --- a/packages/appduct/src/__tests__/mcp-server.integration.test.ts +++ b/packages/appduct/src/__tests__/mcp-server.integration.test.ts @@ -41,8 +41,6 @@ import { DAEMON_VERSION_OVERRIDE_ENV, getPackageVersion } from "../package-versi import { resetDaemonVersionChecks, type SpawnFn } from "../rpc/client.js"; import { makeTempStateDir, removeStateDir } from "./fixtures.js"; -process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; - const runningDaemons: RunningDaemon[] = []; const stateDirs: string[] = []; const mcpHandles: McpServerHandle[] = []; @@ -73,7 +71,6 @@ const failIfCalled = (): never => { type TestDaemon = { daemon: RunningDaemon; stateDir: string; - port: number; }; const startTestDaemon = async (extraConfig: Record = {}): Promise => { @@ -83,10 +80,7 @@ const startTestDaemon = async (extraConfig: Record = {}): Promi const daemon = await startDaemon({ stateDir }); runningDaemons.push(daemon); - // The daemon's `config.json` asks for an OS-assigned port (`wssPort: 0`), so the real port is - // only knowable from the listener that bound it — never pre-picked, which is what used to race - // another vitest process for the same number. - return { daemon, stateDir, port: daemon.listener.port()! }; + return { daemon, stateDir }; }; /** A project root (distinct from the state dir — `appId` resolution never reads the state dir's @@ -148,9 +142,12 @@ const waitForEvent = (daemon: RunningDaemon, kind: string): Promise<{ kind: stri }); }; -const connectClient = (port: number): Promise => { +/** The daemon's `config.json` asks for an OS-assigned port (`wssPort: 0`), so the real port is + * only knowable from the listener that bound it — never pre-picked, which is what used to race + * another vitest process for the same number. */ +const connectClient = (daemon: RunningDaemon): Promise => { return new Promise((resolve, reject) => { - const socket = new WebSocket(`wss://127.0.0.1:${port}`, { rejectUnauthorized: false }); + const socket = new WebSocket(`wss://127.0.0.1:${daemon.listener.port()!}`, { ca: daemon.tls.current().certPem }); socket.once("open", () => resolve(socket)); socket.once("error", reject); }); @@ -186,9 +183,9 @@ const createLinkAndDecode = async ( return { sessionId: decoded!.sessionId, token: decoded!.token }; }; -const claimApp = async (daemon: RunningDaemon, port: number, deviceModel = "Pixel 8"): Promise => { +const claimApp = async (daemon: RunningDaemon, deviceModel = "Pixel 8"): Promise => { const link = await createLinkAndDecode(daemon); - const socket = await connectClient(port); + const socket = await connectClient(daemon); const claimed = waitForEvent(daemon, "session_claimed"); socket.send( @@ -264,8 +261,8 @@ const connectInMemoryClient = async (handle: McpServerHandle): Promise = describe("mcp: calling app tools", () => { test("a tool declaring a timeoutMs above the daemon default gets it, over MCP, end to end (issue #25)", async () => { - const { daemon, stateDir, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const { daemon, stateDir } = await startTestDaemon(); + const app = await claimApp(daemon); // 20 s > DEFAULT_CALL_TIMEOUT_MS (10 s): before this fix the descriptor's timeout never left // the app, the daemon applied its 10 s default, and the answer below arrived to a call that // had already been rejected as `tool_timeout`. The gap between the 11 s reply and this 20 s @@ -320,8 +317,8 @@ describe("mcp: calling app tools", () => { }, 45_000); test("appduct_list_tools passes filter/limit/offset to the real daemon, which rejects bad values as it does for the CLI", async () => { - const { daemon, stateDir, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const { daemon, stateDir } = await startTestDaemon(); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [{ name: "cart_add" }, { name: "cart_clear" }, { name: "login" }]); const handle = await createMcpHandle(stateDir); @@ -346,8 +343,8 @@ describe("mcp: calling app tools", () => { }); test("appduct_list_tools narrows to a group on the real daemon, and grouped tools describe and call like any other", async () => { - const { daemon, stateDir, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const { daemon, stateDir } = await startTestDaemon(); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [ { name: "pay", group: "checkout/payment" }, { name: "begin", group: "checkout" }, @@ -408,8 +405,8 @@ describe("mcp: calling app tools", () => { }); test("tool_call_progress frames map to MCP progress notifications when the client sends a progressToken", async () => { - const { daemon, stateDir, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const { daemon, stateDir } = await startTestDaemon(); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [{ name: "slow" }]); app.socket.on("message", (data) => { @@ -449,8 +446,8 @@ describe("mcp: calling app tools", () => { }); test("an MCP client's notifications/cancelled forwards to the app as tool_cancel (issue #9)", async () => { - const { daemon, stateDir, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const { daemon, stateDir } = await startTestDaemon(); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [{ name: "slow" }]); const receivedByApp: Record[] = []; @@ -1163,7 +1160,7 @@ describe("mcp: appduct_connect / appduct_wait_for_session", () => { }); test("appduct_wait_for_session resolves once a fake client claims the minted session", async () => { - const { daemon, stateDir, port } = await startTestDaemon(); + const { daemon, stateDir } = await startTestDaemon(); const handle = await createMcpHandle(stateDir); const client = await connectInMemoryClient(handle); @@ -1181,7 +1178,7 @@ describe("mcp: appduct_connect / appduct_wait_for_session", () => { CallToolResultSchema, ); - const socket = await connectClient(port); + const socket = await connectClient(daemon); const claimed = waitForEvent(daemon, "session_claimed"); socket.send( JSON.stringify({ @@ -1256,8 +1253,8 @@ describe("mcp: appduct_connect / appduct_wait_for_session", () => { }); test("appduct_wait_for_session returns immediately for a session claimed before it was called", async () => { - const { daemon, stateDir, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const { daemon, stateDir } = await startTestDaemon(); + const app = await claimApp(daemon); const handle = await createMcpHandle(stateDir); const client = await connectInMemoryClient(handle); @@ -1329,8 +1326,8 @@ describe("mcp: appduct_connect / appduct_wait_for_session", () => { describe("mcp: appduct_events / appduct_wait_for_event", () => { test("appduct_events drains app_events already emitted, and honors the returned cursor", async () => { - const { daemon, stateDir, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const { daemon, stateDir } = await startTestDaemon(); + const app = await claimApp(daemon); const emitted = waitForEvent(daemon, "app_event"); app.socket.send(JSON.stringify({ type: "event", session_id: app.sessionId, name: "greeting", payload: { hi: true }, ts: Date.now() })); @@ -1358,8 +1355,8 @@ describe("mcp: appduct_events / appduct_wait_for_event", () => { }); test("appduct_wait_for_event resolves immediately for an event that already fired before the call", async () => { - const { daemon, stateDir, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const { daemon, stateDir } = await startTestDaemon(); + const app = await claimApp(daemon); const emitted = waitForEvent(daemon, "app_event"); app.socket.send( @@ -1387,8 +1384,8 @@ describe("mcp: appduct_events / appduct_wait_for_event", () => { }, 10_000); test("appduct_wait_for_event resolves once a live-only matching event arrives, ignoring non-matching ones", async () => { - const { daemon, stateDir, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const { daemon, stateDir } = await startTestDaemon(); + const app = await claimApp(daemon); const handle = await createMcpHandle(stateDir); const client = await connectInMemoryClient(handle); @@ -1419,8 +1416,8 @@ describe("mcp: appduct_events / appduct_wait_for_event", () => { }, 10_000); test("appduct_wait_for_event rejects with tool_timeout when nothing matches in time", async () => { - const { daemon, stateDir, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const { daemon, stateDir } = await startTestDaemon(); + const app = await claimApp(daemon); const handle = await createMcpHandle(stateDir); const client = await connectInMemoryClient(handle); @@ -1440,8 +1437,8 @@ describe("mcp: appduct_events / appduct_wait_for_event", () => { }, 10_000); test("appduct_wait_for_event's match filters by shallow payload equality", async () => { - const { daemon, stateDir, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const { daemon, stateDir } = await startTestDaemon(); + const app = await claimApp(daemon); const handle = await createMcpHandle(stateDir); const client = await connectInMemoryClient(handle); @@ -1475,8 +1472,8 @@ describe("mcp: appduct_events / appduct_wait_for_event", () => { }, 10_000); test("appduct_wait_for_event rejects match values that could never match (objects/arrays)", async () => { - const { stateDir, port, daemon } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const { stateDir, daemon } = await startTestDaemon(); + const app = await claimApp(daemon); const handle = await createMcpHandle(stateDir); const client = await connectInMemoryClient(handle); @@ -1496,8 +1493,8 @@ describe("mcp: appduct_events / appduct_wait_for_event", () => { }); test("appduct_wait_for_event's since skips an already-retained match and waits for a fresh one", async () => { - const { daemon, stateDir, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const { daemon, stateDir } = await startTestDaemon(); + const app = await claimApp(daemon); const first = waitForEvent(daemon, "app_event"); app.socket.send(JSON.stringify({ type: "event", session_id: app.sessionId, name: "ping", payload: { n: 1 }, ts: Date.now() })); @@ -1556,8 +1553,8 @@ describe("mcp: appduct_events / appduct_wait_for_event", () => { }); test("appduct_events returns app_event only, never lifecycle or tool-call events", async () => { - const { daemon, stateDir, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const { daemon, stateDir } = await startTestDaemon(); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [{ name: "echo" }]); app.socket.on("message", (data) => { @@ -1590,8 +1587,8 @@ describe("mcp: appduct_events / appduct_wait_for_event", () => { }); test("appduct_events and appduct_wait_for_event do not offer kinds and reject it with invalid_request", async () => { - const { daemon, stateDir, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const { daemon, stateDir } = await startTestDaemon(); + const app = await claimApp(daemon); const emitted = waitForEvent(daemon, "app_event"); app.socket.send(JSON.stringify({ type: "event", session_id: app.sessionId, name: "ready", ts: Date.now() })); @@ -1629,8 +1626,8 @@ describe("mcp: appduct_events / appduct_wait_for_event", () => { describe("mcp: appduct://sessions resource", () => { test("lists the resource and reads it back as sessions.list JSON", async () => { - const { daemon, stateDir, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const { daemon, stateDir } = await startTestDaemon(); + const app = await claimApp(daemon); const handle = await createMcpHandle(stateDir); const client = await connectInMemoryClient(handle); @@ -1777,8 +1774,8 @@ describe("mcp: daemon/CLI version drift (issue #30)", () => { }); test("startup against a mismatched daemon with a live session fails with both versions", async () => { - const { daemon, stateDir, port } = await startStaleTestDaemon(); - const app = await claimApp(daemon, port); + const { daemon, stateDir } = await startStaleTestDaemon(); + const app = await claimApp(daemon); const spawn: SpawnFn = () => { throw new Error("the daemon must not be replaced while a session is live"); diff --git a/packages/appduct/src/__tests__/policy-and-audit.integration.test.ts b/packages/appduct/src/__tests__/policy-and-audit.integration.test.ts index 37a4216..aea3ef3 100644 --- a/packages/appduct/src/__tests__/policy-and-audit.integration.test.ts +++ b/packages/appduct/src/__tests__/policy-and-audit.integration.test.ts @@ -25,8 +25,6 @@ import { getStateDirPaths } from "../daemon/state-dir.js"; import { createMcpServer, type McpServerHandle } from "../mcp/server.js"; import { makeTempStateDir, removeStateDir } from "./fixtures.js"; -process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; - const runningDaemons: RunningDaemon[] = []; const stateDirs: string[] = []; const mcpHandles: McpServerHandle[] = []; @@ -48,7 +46,6 @@ afterEach(async () => { type TestDaemon = { daemon: RunningDaemon; stateDir: string; - port: number; }; const startTestDaemon = async (configOverrides: Record = {}): Promise => { @@ -58,10 +55,7 @@ const startTestDaemon = async (configOverrides: Record = {}): P const daemon = await startDaemon({ stateDir }); runningDaemons.push(daemon); - // The daemon's `config.json` asks for an OS-assigned port (`wssPort: 0`), so the real port is - // only knowable from the listener that bound it — never pre-picked, which is what used to race - // another vitest process for the same number. - return { daemon, stateDir, port: daemon.listener.port()! }; + return { daemon, stateDir }; }; /** Removes `daemon` from the tracked list and shuts it down immediately — used mid-test so a @@ -122,9 +116,12 @@ const waitForEvent = (daemon: RunningDaemon, kind: EventKind): Promise => { +/** The daemon's `config.json` asks for an OS-assigned port (`wssPort: 0`), so the real port is + * only knowable from the listener that bound it — never pre-picked, which is what used to race + * another vitest process for the same number. */ +const connectClient = (daemon: RunningDaemon): Promise => { return new Promise((resolve, reject) => { - const socket = new WebSocket(`wss://127.0.0.1:${port}`, { rejectUnauthorized: false }); + const socket = new WebSocket(`wss://127.0.0.1:${daemon.listener.port()!}`, { ca: daemon.tls.current().certPem }); socket.once("open", () => resolve(socket)); socket.once("error", reject); }); @@ -162,9 +159,9 @@ const createLinkAndDecode = async (daemon: RunningDaemon): Promise<{ sessionId: * always slugifies to the deterministic alias `pixel-8` for the first session claimed with that * model (`sessions.ts`'s `slugifyDeviceModel`/`dedupeAlias`) — tests rely on that determinism to * pre-configure `policy.tools["pixel-8/"]` overrides before any claim happens. */ -const claimApp = async (daemon: RunningDaemon, port: number, deviceModel = "Pixel 8"): Promise => { +const claimApp = async (daemon: RunningDaemon, deviceModel = "Pixel 8"): Promise => { const link = await createLinkAndDecode(daemon); - const socket = await connectClient(port); + const socket = await connectClient(daemon); const claimed = waitForEvent(daemon, "session_claimed"); socket.send( @@ -234,8 +231,8 @@ const readAuditRecords = async (stateDir: string): Promise => { describe("policy: evaluate", () => { test("a destructive-hinted tool is denied under policy.destructive: deny, with no frame reaching the app", async () => { - const { daemon, port } = await startTestDaemon({ policy: { destructive: "deny" } }); - const app = await claimApp(daemon, port); + const { daemon } = await startTestDaemon({ policy: { destructive: "deny" } }); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [{ name: "deleteAll", annotations: { destructiveHint: true } }]); const received: Record[] = []; @@ -253,10 +250,10 @@ describe("policy: evaluate", () => { }); test("a per-tool override beats the destructive category rule (deny-by-default, allow-by-override)", async () => { - const { daemon, port } = await startTestDaemon({ + const { daemon } = await startTestDaemon({ policy: { destructive: "deny", tools: { "pixel-8/deleteAll": "allow" } }, }); - const app = await claimApp(daemon, port, "Pixel 8"); + const app = await claimApp(daemon, "Pixel 8"); expect(app.alias).toBe("pixel-8"); await snapshotTools(daemon, app, [{ name: "deleteAll", annotations: { destructiveHint: true } }]); @@ -278,10 +275,10 @@ describe("policy: evaluate", () => { }); test("a per-tool override also beats the default-allow rule (allow-by-default, deny-by-override)", async () => { - const { daemon, port } = await startTestDaemon({ + const { daemon } = await startTestDaemon({ policy: { tools: { "pixel-8/echo": "deny" } }, }); - const app = await claimApp(daemon, port, "Pixel 8"); + const app = await claimApp(daemon, "Pixel 8"); await snapshotTools(daemon, app, [{ name: "echo" }]); await expect( @@ -292,8 +289,8 @@ describe("policy: evaluate", () => { }); test("policy_denied carries a hint naming the config file", async () => { - const { daemon, port, stateDir } = await startTestDaemon({ policy: { destructive: "deny" } }); - const app = await claimApp(daemon, port); + const { daemon, stateDir } = await startTestDaemon({ policy: { destructive: "deny" } }); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [{ name: "deleteAll", annotations: { destructiveHint: true } }]); const paths = getStateDirPaths(stateDir); @@ -359,8 +356,8 @@ describe("policy: prompt without elicitation", () => { }; test('Claude Code without elicitation gets no requiresUserInteraction flag, and its "prompt" call is denied with reason no_consent_channel', async () => { - const { daemon, port, stateDir } = await startTestDaemon({ policy: { tools: { "pixel-8/echo": "prompt" } } }); - const app = await claimApp(daemon, port, "Pixel 8"); + const { daemon, stateDir } = await startTestDaemon({ policy: { tools: { "pixel-8/echo": "prompt" } } }); + const app = await claimApp(daemon, "Pixel 8"); await snapshotTools(daemon, app, [{ name: "echo" }]); let toolCallFrames = 0; @@ -405,8 +402,8 @@ describe("policy: prompt without elicitation", () => { }); test('a legacy consent: "client" from an older MCP server is ignored: the call is denied and audited as no_consent_channel', async () => { - const { daemon, port, stateDir } = await startTestDaemon({ policy: { tools: { "pixel-8/echo": "prompt" } } }); - const app = await claimApp(daemon, port, "Pixel 8"); + const { daemon, stateDir } = await startTestDaemon({ policy: { tools: { "pixel-8/echo": "prompt" } } }); + const app = await claimApp(daemon, "Pixel 8"); await snapshotTools(daemon, app, [{ name: "echo" }]); await expect( @@ -429,8 +426,8 @@ describe("policy: prompt without elicitation", () => { }); test("an unknown consent value is still rejected as an invalid request", async () => { - const { daemon, port } = await startTestDaemon({ policy: { tools: { "pixel-8/echo": "prompt" } } }); - const app = await claimApp(daemon, port, "Pixel 8"); + const { daemon } = await startTestDaemon({ policy: { tools: { "pixel-8/echo": "prompt" } } }); + const app = await claimApp(daemon, "Pixel 8"); await snapshotTools(daemon, app, [{ name: "echo" }]); await expect( @@ -447,8 +444,8 @@ describe("policy: prompt without elicitation", () => { }); test('a "prompt" tool call from an MCP client without elicitation is denied with reason no_consent_channel', async () => { - const { daemon, port, stateDir } = await startTestDaemon({ policy: { tools: { "pixel-8/echo": "prompt" } } }); - const app = await claimApp(daemon, port, "Pixel 8"); + const { daemon, stateDir } = await startTestDaemon({ policy: { tools: { "pixel-8/echo": "prompt" } } }); + const app = await claimApp(daemon, "Pixel 8"); await snapshotTools(daemon, app, [{ name: "echo" }]); const mcpHandle = await createMcpServer({ @@ -478,8 +475,8 @@ describe("policy: prompt without elicitation", () => { }); test('a "prompt" tool call from the CLI (no consent channel) is denied with reason no_consent_channel', async () => { - const { daemon, port, stateDir } = await startTestDaemon({ policy: { tools: { "pixel-8/echo": "prompt" } } }); - const app = await claimApp(daemon, port, "Pixel 8"); + const { daemon, stateDir } = await startTestDaemon({ policy: { tools: { "pixel-8/echo": "prompt" } } }); + const app = await claimApp(daemon, "Pixel 8"); await snapshotTools(daemon, app, [{ name: "echo" }]); await expect( @@ -497,8 +494,8 @@ describe("policy: prompt without elicitation", () => { }); test('policy "deny" is still denied for an MCP client', async () => { - const { daemon, port, stateDir } = await startTestDaemon({ policy: { tools: { "pixel-8/echo": "deny" } } }); - const app = await claimApp(daemon, port, "Pixel 8"); + const { daemon, stateDir } = await startTestDaemon({ policy: { tools: { "pixel-8/echo": "deny" } } }); + const app = await claimApp(daemon, "Pixel 8"); await snapshotTools(daemon, app, [{ name: "echo" }]); const mcpHandle = await createMcpServer({ @@ -527,8 +524,8 @@ describe("policy: prompt without elicitation", () => { }); test('policy "allow" for an MCP client never fabricates consent in the audit record', async () => { - const { daemon, port, stateDir } = await startTestDaemon(); - const app = await claimApp(daemon, port, "Pixel 8"); + const { daemon, stateDir } = await startTestDaemon(); + const app = await claimApp(daemon, "Pixel 8"); await snapshotTools(daemon, app, [{ name: "echo" }]); app.socket.on("message", (data) => { @@ -578,8 +575,8 @@ describe("policy: prompt via MCP elicitation (issue #10)", () => { }; test('appduct_list_tools reports a "prompt" tool\'s policy, and nothing is flagged at listing time — consent is asked at call time', async () => { - const { daemon, port, stateDir } = await startTestDaemon({ policy: { tools: { "pixel-8/echo": "prompt" } } }); - const app = await claimApp(daemon, port, "Pixel 8"); + const { daemon, stateDir } = await startTestDaemon({ policy: { tools: { "pixel-8/echo": "prompt" } } }); + const app = await claimApp(daemon, "Pixel 8"); await snapshotTools(daemon, app, [{ name: "echo" }]); const mcpHandle = await createMcpServer({ stateDir, spawn: () => { throw new Error("must not auto-spawn"); } }); @@ -599,8 +596,8 @@ describe("policy: prompt via MCP elicitation (issue #10)", () => { }); test('a "prompt" tool call accepted via elicitation proceeds, forwards the tool name/session alias/args in the prompt message, and is audited with consent: "elicitation"', async () => { - const { daemon, port, stateDir } = await startTestDaemon({ policy: { tools: { "pixel-8/echo": "prompt" } } }); - const app = await claimApp(daemon, port, "Pixel 8"); + const { daemon, stateDir } = await startTestDaemon({ policy: { tools: { "pixel-8/echo": "prompt" } } }); + const app = await claimApp(daemon, "Pixel 8"); await snapshotTools(daemon, app, [{ name: "echo" }]); app.socket.on("message", (data) => { @@ -640,8 +637,8 @@ describe("policy: prompt via MCP elicitation (issue #10)", () => { }); test('a "prompt" call accepted via elicitation that then errors still records consent: "elicitation"', async () => { - const { daemon, port, stateDir } = await startTestDaemon({ policy: { tools: { "pixel-8/boom": "prompt" } } }); - const app = await claimApp(daemon, port, "Pixel 8"); + const { daemon, stateDir } = await startTestDaemon({ policy: { tools: { "pixel-8/boom": "prompt" } } }); + const app = await claimApp(daemon, "Pixel 8"); await snapshotTools(daemon, app, [{ name: "boom" }]); app.socket.on("message", (data) => { @@ -679,8 +676,8 @@ describe("policy: prompt via MCP elicitation (issue #10)", () => { test.each(["decline", "cancel"] as const)( 'a "prompt" tool call %s\'d via elicitation never reaches the app, and the agent gets an isError result naming it', async (action) => { - const { daemon, port, stateDir } = await startTestDaemon({ policy: { tools: { "pixel-8/echo": "prompt" } } }); - const app = await claimApp(daemon, port, "Pixel 8"); + const { daemon, stateDir } = await startTestDaemon({ policy: { tools: { "pixel-8/echo": "prompt" } } }); + const app = await claimApp(daemon, "Pixel 8"); await snapshotTools(daemon, app, [{ name: "echo" }]); const receivedByApp: Record[] = []; @@ -719,8 +716,8 @@ describe("policy: prompt via MCP elicitation (issue #10)", () => { ); test("an elicitation request the client rejects as unsupported (despite declaring the capability) denies the call exactly like no channel at all — never treated as approval", async () => { - const { daemon, port, stateDir } = await startTestDaemon({ policy: { tools: { "pixel-8/echo": "prompt" } } }); - const app = await claimApp(daemon, port, "Pixel 8"); + const { daemon, stateDir } = await startTestDaemon({ policy: { tools: { "pixel-8/echo": "prompt" } } }); + const app = await claimApp(daemon, "Pixel 8"); await snapshotTools(daemon, app, [{ name: "echo" }]); const mcpHandle = await createMcpServer({ stateDir, spawn: () => { throw new Error("must not auto-spawn"); } }); @@ -753,8 +750,8 @@ describe("policy: prompt via MCP elicitation (issue #10)", () => { }); test("an elicitation that times out denies the call with a decline-shaped result naming the timeout, without calling the daemon", async () => { - const { daemon, port, stateDir } = await startTestDaemon({ policy: { tools: { "pixel-8/echo": "prompt" } } }); - const app = await claimApp(daemon, port, "Pixel 8"); + const { daemon, stateDir } = await startTestDaemon({ policy: { tools: { "pixel-8/echo": "prompt" } } }); + const app = await claimApp(daemon, "Pixel 8"); await snapshotTools(daemon, app, [{ name: "echo" }]); const receivedByApp: Record[] = []; @@ -798,8 +795,8 @@ describe("policy: prompt via MCP elicitation (issue #10)", () => { describe("audit: one line per tools.call attempt", () => { test("ok/error/denied outcomes are all recorded, args are never logged raw, and caller is attributed correctly", async () => { - const { daemon, port, stateDir } = await startTestDaemon({ policy: { destructive: "deny" } }); - const app = await claimApp(daemon, port); + const { daemon, stateDir } = await startTestDaemon({ policy: { destructive: "deny" } }); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [ { name: "echo" }, { name: "boom" }, @@ -899,8 +896,8 @@ describe("audit: one line per tools.call attempt", () => { describe("audit: cancelled outcome", () => { test("tools.cancel followed by the app's tool_cancelled reply audits as cancelled", async () => { - const { daemon, port, stateDir } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const { daemon, stateDir } = await startTestDaemon(); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [{ name: "slow" }]); app.socket.on("message", (data) => { @@ -939,10 +936,10 @@ describe("audit: cancelled outcome", () => { describe("daemon.status: policy + audit surfacing", () => { test("daemon.status exposes the effective policy and the audit path/failedWrites counter", async () => { - const { daemon, port, stateDir } = await startTestDaemon({ + const { daemon, stateDir } = await startTestDaemon({ policy: { default: "allow", destructive: "deny", tools: { "pixel-8/echo": "allow" } }, }); - const app = await claimApp(daemon, port, "Pixel 8"); + const app = await claimApp(daemon, "Pixel 8"); await snapshotTools(daemon, app, [{ name: "echo" }]); app.socket.on("message", (data) => { diff --git a/packages/appduct/src/__tests__/session-engine.integration.test.ts b/packages/appduct/src/__tests__/session-engine.integration.test.ts index a55c66a..3d25253 100644 --- a/packages/appduct/src/__tests__/session-engine.integration.test.ts +++ b/packages/appduct/src/__tests__/session-engine.integration.test.ts @@ -16,11 +16,6 @@ import { decodeBootstrap, type EventKind, type EventNotification } from "@appduc import { startDaemon, type RunningDaemon } from "../daemon/daemon.js"; import { makeTempStateDir, removeStateDir } from "./fixtures.js"; -// Client pinning is the app's job (ARCHITECTURE.md task notes); tests skip it client-side. Under -// Vitest runs these clients against a throwaway self-signed key, so the leaf-cert check is -// disabled process-wide for this file. -process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; - const runningDaemons: RunningDaemon[] = []; const stateDirs: string[] = []; @@ -99,9 +94,9 @@ const waitForEvent = (daemon: RunningDaemon, kind: EventKind): Promise => { +const connectClient = (daemon: RunningDaemon): Promise => { return new Promise((resolve, reject) => { - const socket = new WebSocket(`wss://127.0.0.1:${port}`, { rejectUnauthorized: false }); + const socket = new WebSocket(`wss://127.0.0.1:${daemon.listener.port()!}`, { ca: daemon.tls.current().certPem }); socket.once("open", () => resolve(socket)); socket.once("error", reject); }); @@ -150,7 +145,7 @@ describe("session engine: full lifecycle", () => { // --- device A: mint + claim --- const linkA = await createLinkAndDecode(daemon, port); - const socketA = await connectClient(port); + const socketA = await connectClient(daemon); const claimedA = waitForEvent(daemon, "session_claimed"); socketA.send( @@ -171,7 +166,7 @@ describe("session engine: full lifecycle", () => { // --- device B: distinct alias while A is still connected --- const linkB = await createLinkAndDecode(daemon, port); - const socketB = await connectClient(port); + const socketB = await connectClient(daemon); const claimedB = waitForEvent(daemon, "session_claimed"); socketB.send( JSON.stringify({ @@ -216,7 +211,7 @@ describe("session engine: full lifecycle", () => { })) as { state: string }; expect(describedSuspended.state).toBe("suspended"); - const resumedSocket = await connectClient(port); + const resumedSocket = await connectClient(daemon); const resumed = waitForEvent(daemon, "session_resumed"); resumedSocket.send( JSON.stringify({ @@ -252,7 +247,7 @@ describe("session engine: rejection matrix (daemon and other sessions survive ev const { daemon, port } = await startTestDaemon(); const link = await createLinkAndDecode(daemon, port); - const badSocket = await connectClient(port); + const badSocket = await connectClient(daemon); const closed = nextClose(badSocket); badSocket.send( JSON.stringify({ type: "session_claim", protocol_version: 2, session_id: link.sessionId, token: "wrong-token" }), @@ -262,7 +257,7 @@ describe("session engine: rejection matrix (daemon and other sessions survive ev expect(closeInfo.reason).toBe("invalid_token"); // The link survives a single bad attempt: a correct claim still succeeds. - const goodSocket = await connectClient(port); + const goodSocket = await connectClient(daemon); goodSocket.send( JSON.stringify({ type: "session_claim", protocol_version: 2, session_id: link.sessionId, token: link.token }), ); @@ -281,7 +276,7 @@ describe("session engine: rejection matrix (daemon and other sessions survive ev let lastClose: { code: number; reason: string } | undefined; for (let attempt = 0; attempt < 5; attempt += 1) { - const socket = await connectClient(port); + const socket = await connectClient(daemon); const closed = nextClose(socket); socket.send( JSON.stringify({ type: "session_claim", protocol_version: 2, session_id: link.sessionId, token: "wrong" }), @@ -293,7 +288,7 @@ describe("session engine: rejection matrix (daemon and other sessions survive ev expect(lastClose?.reason).toBe("claim_attempts_exceeded"); // The link is now gone outright, even with the correct token. - const finalSocket = await connectClient(port); + const finalSocket = await connectClient(daemon); const finalClosed = nextClose(finalSocket); finalSocket.send( JSON.stringify({ type: "session_claim", protocol_version: 2, session_id: link.sessionId, token: link.token }), @@ -314,7 +309,7 @@ describe("session engine: rejection matrix (daemon and other sessions survive ev // SUSPENDED -> EXPIRED grace-window transition — ARCHITECTURE.md §5/§6). await waitForEvent(daemon, "link_expired"); - const socket = await connectClient(port); + const socket = await connectClient(daemon); const closed = nextClose(socket); socket.send( JSON.stringify({ type: "session_claim", protocol_version: 2, session_id: link.sessionId, token: link.token }), @@ -328,7 +323,7 @@ describe("session engine: rejection matrix (daemon and other sessions survive ev const { daemon, port } = await startTestDaemon(); const link = await createLinkAndDecode(daemon, port); - const socket = await connectClient(port); + const socket = await connectClient(daemon); socket.send( JSON.stringify({ type: "session_claim", protocol_version: 2, session_id: link.sessionId, token: link.token }), ); @@ -340,7 +335,7 @@ describe("session engine: rejection matrix (daemon and other sessions survive ev await suspended; // Resume once to rotate the token... - const resumeSocket = await connectClient(port); + const resumeSocket = await connectClient(daemon); const resumed = waitForEvent(daemon, "session_resumed"); resumeSocket.send( JSON.stringify({ @@ -358,7 +353,7 @@ describe("session engine: rejection matrix (daemon and other sessions survive ev resumeSocket.close(); await suspendedAgain; - const staleAttempt = await connectClient(port); + const staleAttempt = await connectClient(daemon); const staleClosed = nextClose(staleAttempt); staleAttempt.send( JSON.stringify({ @@ -377,7 +372,7 @@ describe("session engine: rejection matrix (daemon and other sessions survive ev const { daemon, port } = await startTestDaemon(); const link = await createLinkAndDecode(daemon, port); - const socket = await connectClient(port); + const socket = await connectClient(daemon); socket.send( JSON.stringify({ type: "session_claim", protocol_version: 2, session_id: link.sessionId, token: link.token }), ); @@ -397,8 +392,8 @@ describe("session engine: rejection matrix (daemon and other sessions survive ev }); test("binary frame closes 1003", async () => { - const { port } = await startTestDaemon(); - const socket = await connectClient(port); + const { daemon } = await startTestDaemon(); + const socket = await connectClient(daemon); const closed = nextClose(socket); socket.send(Buffer.from([1, 2, 3])); const closeInfo = await closed; @@ -406,16 +401,16 @@ describe("session engine: rejection matrix (daemon and other sessions survive ev }); test("a frame over 256 KiB is rejected", async () => { - const { port } = await startTestDaemon(); - const socket = await connectClient(port); + const { daemon } = await startTestDaemon(); + const socket = await connectClient(daemon); const closed = new Promise((resolve) => socket.once("close", () => resolve())); socket.send(JSON.stringify({ type: "session_claim", padding: "x".repeat(300 * 1024) })); await closed; }); test("malformed JSON closes 1008 invalid_json", async () => { - const { port } = await startTestDaemon(); - const socket = await connectClient(port); + const { daemon } = await startTestDaemon(); + const socket = await connectClient(daemon); const closed = nextClose(socket); socket.send("{ not json"); const closeInfo = await closed; @@ -424,9 +419,9 @@ describe("session engine: rejection matrix (daemon and other sessions survive ev }); test("unclaimed socket idle past the pre-claim timeout closes 1008 pre_claim_timeout", async () => { - const { port } = await startTestDaemon(); + const { daemon } = await startTestDaemon(); - const socket = await connectClient(port); + const socket = await connectClient(daemon); const closed = nextClose(socket); const closeInfo = await closed; expect(closeInfo.code).toBe(1008); @@ -437,7 +432,7 @@ describe("session engine: rejection matrix (daemon and other sessions survive ev const { daemon, port } = await startTestDaemon(); const link = await createLinkAndDecode(daemon, port); - const socket = await connectClient(port); + const socket = await connectClient(daemon); socket.send( JSON.stringify({ type: "session_claim", protocol_version: 2, session_id: link.sessionId, token: link.token }), ); @@ -454,7 +449,7 @@ describe("session engine: rejection matrix (daemon and other sessions survive ev const { daemon, port } = await startTestDaemon(); const link = await createLinkAndDecode(daemon, port); - const socket = await connectClient(port); + const socket = await connectClient(daemon); socket.send( JSON.stringify({ type: "session_claim", protocol_version: 2, session_id: link.sessionId, token: link.token }), ); @@ -480,7 +475,7 @@ describe("session engine: rejection matrix (daemon and other sessions survive ev const { daemon, port } = await startTestDaemon(); const link = await createLinkAndDecode(daemon, port); - const socket = await connectClient(port); + const socket = await connectClient(daemon); socket.send( JSON.stringify({ type: "session_claim", protocol_version: 2, session_id: link.sessionId, token: link.token }), ); @@ -503,14 +498,14 @@ describe("session selectors", () => { }); const linkA = await createLinkAndDecode(daemon, port); - const socketA = await connectClient(port); + const socketA = await connectClient(daemon); socketA.send( JSON.stringify({ type: "session_claim", protocol_version: 2, session_id: linkA.sessionId, token: linkA.token }), ); await nextMessage(socketA); const linkB = await createLinkAndDecode(daemon, port); - const socketB = await connectClient(port); + const socketB = await connectClient(daemon); socketB.send( JSON.stringify({ type: "session_claim", protocol_version: 2, session_id: linkB.sessionId, token: linkB.token }), ); diff --git a/packages/appduct/src/__tests__/tls-refresh.integration.test.ts b/packages/appduct/src/__tests__/tls-refresh.integration.test.ts index 760c134..f774f37 100644 --- a/packages/appduct/src/__tests__/tls-refresh.integration.test.ts +++ b/packages/appduct/src/__tests__/tls-refresh.integration.test.ts @@ -19,9 +19,6 @@ import WebSocket from "ws"; import { startDaemon, type RunningDaemon } from "../daemon/daemon.js"; import { makeTempStateDir, removeStateDir } from "./fixtures.js"; -// Client pinning is the app's job; tests skip it client-side for their throwaway self-signed key. -process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; - const runningDaemons: RunningDaemon[] = []; const stateDirs: string[] = []; @@ -69,9 +66,9 @@ const rpcCall = (socketPath: string, method: string, params?: unknown): Promise< }); }; -const connectClient = (port: number): Promise => { +const connectClient = (daemon: RunningDaemon): Promise => { return new Promise((resolve, reject) => { - const socket = new WebSocket(`wss://127.0.0.1:${port}`, { rejectUnauthorized: false }); + const socket = new WebSocket(`wss://127.0.0.1:${daemon.listener.port()!}`, { ca: daemon.tls.current().certPem }); socket.once("open", () => resolve(socket)); socket.once("error", reject); }); @@ -112,8 +109,7 @@ describe("TLS re-mint on advertised-IP change", () => { // must still succeed (this is exactly what a bare cert/key swap without `setSecureContext` // would fail to achieve: the old context would keep serving the stale SAN, or worse, the server // would need a restart). - // The port the listener actually bound (the config asked for an OS-assigned one). - const socket = await connectClient(daemon.listener.port()!); + const socket = await connectClient(daemon); socket.close(); const status = (await rpcCall(daemon.paths.socketPath, "daemon.status")) as { pid: number }; diff --git a/packages/appduct/src/__tests__/tool-invocation.integration.test.ts b/packages/appduct/src/__tests__/tool-invocation.integration.test.ts index 4ec3f3a..4cc699e 100644 --- a/packages/appduct/src/__tests__/tool-invocation.integration.test.ts +++ b/packages/appduct/src/__tests__/tool-invocation.integration.test.ts @@ -24,9 +24,6 @@ import { handleInvokeCommand } from "../commands/invoke.js"; import { startDaemon, type RunningDaemon } from "../daemon/daemon.js"; import { makeTempStateDir, removeStateDir } from "./fixtures.js"; -// Client pinning is the app's job; tests skip it client-side for their throwaway self-signed key. -process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; - const runningDaemons: RunningDaemon[] = []; const stateDirs: string[] = []; @@ -40,22 +37,14 @@ afterEach(async () => { } }); -type TestDaemon = { - daemon: RunningDaemon; - port: number; -}; - -const startTestDaemon = async (configOverrides: Record = {}): Promise => { +const startTestDaemon = async (configOverrides: Record = {}): Promise => { const stateDir = await makeTempStateDir(configOverrides, { prefix: "appduct-tool-invocation-" }); stateDirs.push(stateDir); const daemon = await startDaemon({ stateDir }); runningDaemons.push(daemon); - // The daemon's `config.json` asks for an OS-assigned port (`wssPort: 0`), so the real port is - // only knowable from the listener that bound it — never pre-picked, which is what used to race - // another vitest process for the same number. - return { daemon, port: daemon.listener.port()! }; + return daemon; }; /** Raw newline-delimited JSON-RPC call over the daemon's UDS control socket. */ @@ -188,9 +177,12 @@ const waitForEvent = (daemon: RunningDaemon, kind: EventKind): Promise => { +/** The daemon's `config.json` asks for an OS-assigned port (`wssPort: 0`), so the real port is + * only knowable from the listener that bound it — never pre-picked, which is what used to race + * another vitest process for the same number. */ +const connectClient = (daemon: RunningDaemon): Promise => { return new Promise((resolve, reject) => { - const socket = new WebSocket(`wss://127.0.0.1:${port}`, { rejectUnauthorized: false }); + const socket = new WebSocket(`wss://127.0.0.1:${daemon.listener.port()!}`, { ca: daemon.tls.current().certPem }); socket.once("open", () => resolve(socket)); socket.once("error", reject); }); @@ -215,10 +207,7 @@ type ClaimedApp = { resumeToken: string; }; -const createLinkAndDecode = async ( - daemon: RunningDaemon, - port: number, -): Promise<{ sessionId: string; token: string }> => { +const createLinkAndDecode = async (daemon: RunningDaemon): Promise<{ sessionId: string; token: string }> => { const result = (await rpcCall(daemon.paths.socketPath, "link.create", { ttlSeconds: 60 })) as { deepLinkPayload: string; }; @@ -229,9 +218,9 @@ const createLinkAndDecode = async ( }; /** Mints a link, claims it over a fresh socket, and returns the claimed app connection. */ -const claimApp = async (daemon: RunningDaemon, port: number, deviceModel = "Pixel 8"): Promise => { - const link = await createLinkAndDecode(daemon, port); - const socket = await connectClient(port); +const claimApp = async (daemon: RunningDaemon, deviceModel = "Pixel 8"): Promise => { + const link = await createLinkAndDecode(daemon); + const socket = await connectClient(daemon); const claimed = waitForEvent(daemon, "session_claimed"); socket.send( @@ -268,8 +257,8 @@ const snapshotTools = async ( describe("tools.list / tools.call: round trip", () => { test("list -> call -> result round-trip, including schemas visible in tools.list", async () => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [ { @@ -309,8 +298,8 @@ describe("tools.list / tools.call: round trip", () => { }); test("tools.list on a SUSPENDED session still returns the retained registry", async () => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [{ name: "echo" }]); const suspended = waitForEvent(daemon, "session_suspended"); @@ -324,8 +313,8 @@ describe("tools.list / tools.call: round trip", () => { }); test("tools.call for an unregistered tool rejects tool_not_found without sending a frame to the app", async () => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [{ name: "echo" }]); let sawToolCall = false; @@ -345,8 +334,8 @@ describe("tools.list / tools.call: round trip", () => { }); test("tools.call rejects invalid_request when args is not a JSON object", async () => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [{ name: "echo" }]); await expect( @@ -357,8 +346,8 @@ describe("tools.list / tools.call: round trip", () => { }); test("tools.list sorts by name, filters on name/description, reports total before paging, and slices with limit/offset", async () => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); // Registered out of alphabetical order on purpose. await snapshotTools(daemon, app, [ @@ -404,8 +393,8 @@ describe("tools.list / tools.call: round trip", () => { }); test("tools.list rejects a bad limit/offset/filter as invalid_request", async () => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [{ name: "echo" }]); await expect( @@ -438,8 +427,8 @@ describe("tools.list / tools.call: round trip", () => { }); test("tools.list narrows by group segment before filter, total and paging; groups always reflects the whole registry", async () => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [ { name: "add_item", description: "Adds to the cart.", group: "cart" }, @@ -510,8 +499,8 @@ describe("tools.list / tools.call: round trip", () => { }); test("tools.list on a registry with no groups returns only the ungrouped bucket", async () => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [{ name: "echo" }, { name: "ping" }]); const listing = (await rpcCall(daemon.paths.socketPath, "tools.list", { selector: app.alias })) as { @@ -527,8 +516,8 @@ describe("tools.call: error type preservation", () => { test.each(TOOL_ERROR_TYPES.map((type) => [type] as const))( "app tool_error type %s is preserved verbatim end-to-end", async (errorType) => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [{ name: "boom" }]); app.socket.on("message", (data) => { @@ -557,8 +546,8 @@ describe("tools.call: error type preservation", () => { describe("tools.call: timeout", () => { test("app never replies -> tool_timeout after the configured timeoutMs", async () => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [{ name: "hangs" }]); // The app receives the tool_call and does nothing (simulating a hung handler). @@ -575,8 +564,8 @@ describe("tools.call: timeout", () => { }, 5000); test("the tool's own declared timeoutMs is the default when the caller passes none (issue #25)", async () => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); // Declared well below DEFAULT_CALL_TIMEOUT_MS (10 s) so the assertion below distinguishes the // two: if the descriptor's timeout were still being dropped on the wire, this call would sit // there for 10 s and blow the 5 s test budget. @@ -596,8 +585,8 @@ describe("tools.call: timeout", () => { }, 10_000); test("an explicit caller timeoutMs still shortens a longer declared deadline (issue #25)", async () => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [{ name: "hangs", timeout_ms: 600_000 }]); const startedAt = Date.now(); @@ -619,8 +608,8 @@ describe("caller transport timeouts over a slow tool", () => { test( "invoke (with and without --timeout) and appduct/client all outlive the daemon's 10 s default (issue #25)", async () => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [ { name: "slow-explicit" }, // Declares its own deadline, so a caller that passes none still gets 20 s daemon-side. @@ -679,8 +668,8 @@ describe("caller transport timeouts over a slow tool", () => { describe("tools.call: concurrency", () => { test("three concurrent calls interleaved out of order resolve to the right callers", async () => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [{ name: "a" }, { name: "b" }, { name: "c" }]); const received: Array<{ id: string; name: string }> = []; @@ -725,8 +714,8 @@ describe("tools.call: concurrency", () => { describe("tools.call: suspend mid-call", () => { test("suspend rejects the pending call with session_suspended; a new call succeeds after resume", async () => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [{ name: "echo" }]); // The app receives the tool_call but the test never answers it — the socket is dropped instead. @@ -753,7 +742,7 @@ describe("tools.call: suspend mid-call", () => { await expect(pendingCall).rejects.toMatchObject({ data: { type: "session_suspended" } }); // Resume on a fresh socket, re-send the authoritative snapshot, then a fresh call succeeds. - const resumedSocket = await connectClient(port); + const resumedSocket = await connectClient(daemon); const resumed = waitForEvent(daemon, "session_resumed"); resumedSocket.send( JSON.stringify({ @@ -791,8 +780,8 @@ describe("tools.call: suspend mid-call", () => { describe("tools.cancel", () => { test("sends tool_cancel to the app; the app's tool_cancelled reply rejects the pending call", async () => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [{ name: "slow" }]); const cancelMessages: Record[] = []; @@ -829,8 +818,8 @@ describe("tools.cancel", () => { }); test("cancelling an unknown or already-finished callId is a no-op, not an error", async () => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); const result = await rpcCall(daemon.paths.socketPath, "tools.cancel", { selector: app.alias, @@ -844,8 +833,8 @@ describe("tools.cancel", () => { describe("tools.call: cancel on connection drop", () => { test("the connection that issued tools.call dropping mid-flight sends tool_cancel to the app", async () => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [{ name: "slow" }]); const gotToolCall = new Promise((resolve) => { @@ -883,12 +872,12 @@ describe("tools.call: cancel on connection drop", () => { describe("events.subscribe", () => { test("a subscriber receives session_claimed, tools_changed, app_event, tool_call_started/finished in order", async () => { - const { daemon, port } = await startTestDaemon(); + const daemon = await startTestDaemon(); const connection = await openRpcConnection(daemon.paths.socketPath); await connection.call("events.subscribe", {}); - const app = await claimApp(daemon, port); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [{ name: "echo" }]); app.socket.send(JSON.stringify({ type: "event", session_id: app.sessionId, name: "custom_event", ts: Date.now() })); @@ -933,12 +922,12 @@ describe("events.subscribe", () => { }); test("a subscriber with a kinds filter receives only the filtered subset", async () => { - const { daemon, port } = await startTestDaemon(); + const daemon = await startTestDaemon(); const filtered = await openRpcConnection(daemon.paths.socketPath); await filtered.call("events.subscribe", { kinds: ["tools_changed"] }); - const app = await claimApp(daemon, port); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [{ name: "echo" }]); await new Promise((resolve) => setTimeout(resolve, 50)); @@ -951,12 +940,12 @@ describe("events.subscribe", () => { app.socket.close(); }); test("a subscriber with kinds [session_claimed] still receives the claim live", async () => { - const { daemon, port } = await startTestDaemon(); + const daemon = await startTestDaemon(); const filtered = await openRpcConnection(daemon.paths.socketPath); await filtered.call("events.subscribe", { kinds: ["session_claimed"] }); - const app = await claimApp(daemon, port); + const app = await claimApp(daemon); await new Promise((resolve) => setTimeout(resolve, 50)); expect(filtered.notifications.map((n) => [n.kind, n.sessionId])).toEqual([["session_claimed", app.sessionId]]); @@ -966,8 +955,8 @@ describe("events.subscribe", () => { }); test("a subscriber with kinds [tool_call_started, tool_call_progress] still receives both live", async () => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [{ name: "slow" }]); const filtered = await openRpcConnection(daemon.paths.socketPath); @@ -996,8 +985,8 @@ describe("events.subscribe", () => { describe("events.since", () => { test("returns app_event only, never the session's lifecycle or tool-call events", async () => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [{ name: "echo" }]); app.socket.on("message", (data) => { @@ -1024,8 +1013,8 @@ describe("events.since", () => { }); test("an older client passing kinds still gets app events only", async () => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); const emitted = waitForEvent(daemon, "app_event"); app.socket.send(JSON.stringify({ type: "event", session_id: app.sessionId, name: "hello", ts: Date.now() })); @@ -1041,8 +1030,8 @@ describe("events.since", () => { }); test("an app event posted before eventBufferSize tool calls is still returned", async () => { - const { daemon, port } = await startTestDaemon({ eventBufferSize: 4 }); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon({ eventBufferSize: 4 }); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [{ name: "echo" }]); const emitted = waitForEvent(daemon, "app_event"); @@ -1071,8 +1060,8 @@ describe("events.since", () => { }); test("drains retained app_events with no live subscription, and cursor advances", async () => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); const first = waitForEvent(daemon, "app_event"); app.socket.send(JSON.stringify({ type: "event", session_id: app.sessionId, name: "a", ts: Date.now() })); @@ -1102,8 +1091,8 @@ describe("events.since", () => { }); test("selector defaults to the sole active/suspended session, matching sessions.describe", async () => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); const emitted = waitForEvent(daemon, "app_event"); app.socket.send(JSON.stringify({ type: "event", session_id: app.sessionId, name: "solo", ts: Date.now() })); @@ -1118,8 +1107,8 @@ describe("events.since", () => { }); test("a terminal transition discards the retained buffer", async () => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); const emitted = waitForEvent(daemon, "app_event"); app.socket.send(JSON.stringify({ type: "event", session_id: app.sessionId, name: "before-revoke", ts: Date.now() })); @@ -1135,14 +1124,14 @@ describe("events.since", () => { }); test("no_session and ambiguous_session error types, matching every other selector-taking method", async () => { - const { daemon, port } = await startTestDaemon(); + const daemon = await startTestDaemon(); await expect(rpcCall(daemon.paths.socketPath, "events.since", {})).rejects.toMatchObject({ data: { type: "no_session" }, }); - const appA = await claimApp(daemon, port, "Pixel 8"); - const appB = await claimApp(daemon, port, "Pixel 8"); + const appA = await claimApp(daemon, "Pixel 8"); + const appB = await claimApp(daemon, "Pixel 8"); await expect(rpcCall(daemon.paths.socketPath, "events.since", {})).rejects.toMatchObject({ data: { type: "ambiguous_session" }, @@ -1153,8 +1142,8 @@ describe("events.since", () => { }); test("limit keeps the oldest N and cursor pages forward, never skipping a retained event", async () => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); for (const name of ["a", "b", "c"]) { const emitted = waitForEvent(daemon, "app_event"); @@ -1186,8 +1175,8 @@ describe("events.since", () => { }); test("tool_call_progress is fanned out live but never retained, so it can't evict app_events", async () => { - const { daemon, port } = await startTestDaemon(); - const app = await claimApp(daemon, port); + const daemon = await startTestDaemon(); + const app = await claimApp(daemon); await snapshotTools(daemon, app, [{ name: "slow" }]); const appEventEmitted = waitForEvent(daemon, "app_event"); @@ -1223,14 +1212,14 @@ describe("events.since", () => { describe("tools.* selectors", () => { test("unknown_session and ambiguous_session error types", async () => { - const { daemon, port } = await startTestDaemon(); + const daemon = await startTestDaemon(); await expect( rpcCall(daemon.paths.socketPath, "tools.call", { selector: "does-not-exist", name: "echo", args: {} }), ).rejects.toMatchObject({ data: { type: "unknown_session" } }); - const appA = await claimApp(daemon, port, "Pixel 8"); - const appB = await claimApp(daemon, port, "Pixel 8"); + const appA = await claimApp(daemon, "Pixel 8"); + const appB = await claimApp(daemon, "Pixel 8"); await expect(rpcCall(daemon.paths.socketPath, "tools.list")).rejects.toMatchObject({ data: { type: "ambiguous_session" }, From 5c431ac6829e656349e06650ed83461f953603f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 14:47:45 +0000 Subject: [PATCH 3/5] test: TLS bypass rule covers vi.stubEnv and the global agent (#104) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UHcZfMyTBtKc72KVYQw4rb --- .../appduct/src/__tests__/lint-boundaries.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/appduct/src/__tests__/lint-boundaries.test.ts b/packages/appduct/src/__tests__/lint-boundaries.test.ts index b920e63..13f9e71 100644 --- a/packages/appduct/src/__tests__/lint-boundaries.test.ts +++ b/packages/appduct/src/__tests__/lint-boundaries.test.ts @@ -103,6 +103,21 @@ describe("lint: TLS verification in tests", () => { expect(rules).toContain("appduct/no-tls-bypass"); }); + test("stubbing the environment variable through vitest fails", async () => { + const rules = await lint("packages/appduct/src/__tests__/probe.integration.test.ts", 'import { vi } from "vitest";\nvi.stubEnv("NODE_TLS_REJECT_UNAUTHORIZED", "0");\n'); + expect(rules).toContain("appduct/no-tls-bypass"); + }); + + test("stubbing an unrelated environment variable passes", async () => { + const rules = await lint("packages/appduct/src/__tests__/probe.integration.test.ts", 'import { vi } from "vitest";\nvi.stubEnv("APPDUCT_HOME", "/tmp/probe");\n'); + expect(rules).toEqual([]); + }); + + test("switching off verification on the global HTTPS agent fails", async () => { + const rules = await lint("packages/appduct/src/__tests__/probe.integration.test.ts", 'import https from "node:https";\nhttps.globalAgent.options.rejectUnauthorized = false;\n'); + expect(rules).toContain("appduct/no-tls-bypass"); + }); + test("a test client passing rejectUnauthorized: false fails", async () => { const rules = await lint( "packages/appduct/src/__tests__/probe.integration.test.ts", From ac9db9863f30f790925bb2a46c7fe3458afc19c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 14:50:20 +0000 Subject: [PATCH 4/5] chore(lint): catch vi.stubEnv and global agent TLS bypasses (#104) 2 failing -> 0 failing Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UHcZfMyTBtKc72KVYQw4rb --- eslint.config.mjs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 1523e2f..b85cd3a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -134,15 +134,21 @@ const noTlsBypass = { meta: { type: "problem", docs: { description: "no disabling TLS verification in tests" }, schema: [], messages: { bypass: TLS_MESSAGE } }, create(context) { const report = (node) => context.report({ node, messageId: "bypass" }); + const isFalse = (node) => node.type === "Literal" && node.value === false; return { Property(node) { const key = node.computed ? undefined : keyName(node.key); if (key === "NODE_TLS_REJECT_UNAUTHORIZED") report(node); - if (key === "rejectUnauthorized" && node.value.type === "Literal" && node.value.value === false) report(node); + if (key === "rejectUnauthorized" && isFalse(node.value)) report(node); }, AssignmentExpression(node) { - const target = node.left; - if (target.type === "MemberExpression" && keyName(target.property) === "NODE_TLS_REJECT_UNAUTHORIZED") report(node); + const key = node.left.type === "MemberExpression" ? keyName(node.left.property) : undefined; + if (key === "NODE_TLS_REJECT_UNAUTHORIZED") report(node); + if (key === "rejectUnauthorized" && isFalse(node.right)) report(node); + }, + // vi.stubEnv("NODE_TLS_REJECT_UNAUTHORIZED", "0"), Reflect.set(process.env, "NODE_TLS_REJECT_UNAUTHORIZED", ...) + CallExpression(node) { + if (node.arguments.some((arg) => arg.type === "Literal" && arg.value === "NODE_TLS_REJECT_UNAUTHORIZED")) report(node); }, }; }, From 528e58d928fd1f38261e731eae3430eaf202244a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 14:56:53 +0000 Subject: [PATCH 5/5] chore(memory): note missed bypass spellings on #108 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UHcZfMyTBtKc72KVYQw4rb --- .agents/memory/INBOX.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.agents/memory/INBOX.md b/.agents/memory/INBOX.md index f0b4bdc..cb6a531 100644 --- a/.agents/memory/INBOX.md +++ b/.agents/memory/INBOX.md @@ -23,3 +23,7 @@ One note per PR that hit friction, four lines: Would have prevented it: at the start, check for a simulator (xcrun, or an Android emulator with KVM) and, if there is none, ask the human up front to run e2e-device locally. Cost: blocked Seen: 2026-09-24 +- 2026-09-24 #108 skill: implement-issue + What went wrong: the new no-tls-bypass lint rule's red tests covered only the literal spellings in the issue, so `vi.stubEnv(...)` and `globalAgent.options.rejectUnauthorized = false` got through. + Would have prevented it: when an issue asks a lint rule to catch "equivalent" forms, write a red test for each way the pattern can be spelled (assignment, call argument, member assignment, object property) before implementing. + Cost: review round