Files
filip131311 7f85f996aa fix(tool-server): defer to an existing server on EADDRINUSE instead of crash-looping (#533)
## Summary

Fixes **bucket B** of the 0.16.0 crash telemetry — the `toolserver_bind`
/ `EADDRINUSE` restart loop. It's the largest crash **volume** (~817
events, one machine alone = 785) even though it's a narrow user set
(10). Companion to #531 (which fixed the widespread multi-user bucket
A).

## Root cause (reproduced live)

The main HTTP listener's `server.on("error")` handler routed **every**
bind error through `crashShutdown` (exit 1). When a second tool-server
starts on an already-owned port, that exit just feeds a supervisor
respawn → re-bind → crash, forever.

The telemetry signature was puzzling: all these crashes are
`crash_phase=serving` with ~1s uptime and **0 tool calls** — i.e. the
listener *bound successfully* and then died. I reproduced it: on macOS,
a **second tool-server process's** `listen()` momentarily **succeeds**
(the `listening` callback runs → `listening=true` → phase `serving`),
and **then** the server emits an asynchronous `EADDRINUSE`. So the
redundant instance binds, immediately errors, crashes, and the
supervisor loops it (~1s each, 785× on one machine).

Live A/B on the compiled `dist` (isolated port, telemetry disabled),
server A up then a second server B on the same port:

```
buggy (current 0.16.0):  B: "Tools server listening ..."  → "Failed to bind ... EADDRINUSE"  → exit 1  (→ respawn loop)
fixed:                   B: "Tools server listening ..."  → "deferring to it (redundant instance)" → exit 0
```

A survives in both.

## Fix

Handle `EADDRINUSE` specifically instead of crash-looping:

- Probe the port for a healthy argent tool-server (`GET /tools` → 200,
or 401 when a token is configured).
- **If a healthy peer answers**, this instance is redundant → shut down
**cleanly** (`shutdown(0)`) and defer to it. This removes it from the
`reason:"crash"` restart-loop population entirely.
- **If nothing healthy owns the port** (a foreign holder, or a wedged
peer that doesn't answer within the probe timeout) → still crash, so a
genuinely stuck port stays visible.
- `EACCES` and all other server errors crash exactly as before.

It also corrects the now-false comment claiming these crashes are phased
`startup` ("the listen callback never ran"). Telemetry and the live
repro prove the bind succeeds first, so `listening=true` and the phase
is genuinely `serving`.

## Testing

- **New unit tests** for `probeArgentToolServer`: peer answering 200 or
401 → `true`; foreign 500, connection-refused, and wedged/timeout →
`false`.
- Full tool-server suite **2731 green**; `tsc` + `eslint` clean.
- **Live A/B** above, on the built dist.

## Note on scope

This is a robustness fix that breaks the loop for the redundant-instance
case (the dominant one). The precise reason macOS lets a second
process's bind succeed-then-error asynchronously (vs. failing
immediately, as same-process contention does) is a kernel/loopback
timing detail; the fix does not depend on it — any `EADDRINUSE`,
immediate or deferred, now defers-or-crashes deterministically rather
than hot-looping.
2026-07-22 16:56:54 +02:00

675 lines
21 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { FAILURE_CODES } from "@argent/registry";
import { sanitize, ALLOWED } from "../src/sanitize.js";
import { EVENT_NAMES, PLATFORMS } from "../src/events.js";
describe("sanitize", () => {
describe("event allowlist", () => {
it("returns empty object for an unknown event name", () => {
expect(sanitize("attack:pwned", { foo: 1 })).toEqual({});
});
it("drops every unknown property key", () => {
expect(
sanitize("installation:cli_init_start", {
package_manager: "npm",
is_non_interactive: true,
home_path: "/Users/alice", // not in allowlist
ssn: "123-45-6789",
})
).toEqual({ package_manager: "npm", is_non_interactive: true });
});
});
describe("oneOf validator", () => {
it("accepts canonical values", () => {
expect(sanitize("installation:cli_init_start", { package_manager: "npm" })).toEqual({
package_manager: "npm",
});
});
it("drops a fork-derived path-leaking value", () => {
expect(
sanitize("installation:cli_init_start", {
package_manager: "npm (/Users/alice/.nvm/versions/node/v20/bin/npm)",
})
).toEqual({});
});
it("drops unknown enum values", () => {
expect(sanitize("installation:cli_init_cancel", { step: "scope_typo" })).toEqual({});
});
it("drops the legacy unknown tool platform value", () => {
expect(sanitize("tool:invoke", { tool: "list-devices", platform: "unknown" })).toEqual({
tool: "list-devices",
});
});
it("accepts every telemetry platform, including the TV variants", () => {
// The TV variants (`tvos`, `android-tv`) are telemetry-only refinements of
// `ios` / `android`; the sanitizer must let them through so the split
// survives to PostHog.
for (const platform of PLATFORMS) {
expect(sanitize("tool:invoke", { tool: "describe", platform })).toEqual({
tool: "describe",
platform,
});
}
expect(PLATFORMS).toContain("tvos");
expect(PLATFORMS).toContain("android-tv");
});
it("drops the legacy `from_tar` decision (developer-only path is off the books)", () => {
expect(sanitize("installation:global_install_decision", { decision: "from_tar" })).toEqual(
{}
);
});
it("accepts the documented global_install_decision enum values", () => {
for (const decision of ["install", "cancel", "already_installed"] as const) {
expect(sanitize("installation:global_install_decision", { decision })).toEqual({
decision,
});
}
});
it("accepts a coarse AI client slug on tool events", () => {
expect(
sanitize("tool:invoke", {
tool: "gesture-tap",
tool_invocation_id: "11111111-1111-4111-8111-111111111111",
ai_client: "codex",
})
).toEqual({
tool: "gesture-tap",
tool_invocation_id: "11111111-1111-4111-8111-111111111111",
ai_client: "codex",
});
});
it("never carries the free-form ai_client_name — it is not an allowed key", () => {
// The free-form name was dropped end-to-end; `other` is recorded as a bare
// bucket so a client that names itself after the host/user can't leak it.
expect(
sanitize("tool:invoke", {
tool: "gesture-tap",
tool_invocation_id: "11111111-1111-4111-8111-111111111111",
ai_client: "other",
ai_client_name: "some-new-tool",
})
).toEqual({
tool: "gesture-tap",
tool_invocation_id: "11111111-1111-4111-8111-111111111111",
ai_client: "other",
});
});
it("drops unregistered AI client slugs", () => {
expect(
sanitize("tool:invoke", {
tool: "gesture-tap",
tool_invocation_id: "11111111-1111-4111-8111-111111111111",
ai_client: "my-private-client",
ai_client_name: "/Users/alice/secret-client",
})
).toEqual({
tool: "gesture-tap",
tool_invocation_id: "11111111-1111-4111-8111-111111111111",
});
});
it("carries the coarse AI client on tool:fail alongside the failure signal", () => {
const result = sanitize("tool:fail", {
tool: "screenshot",
tool_invocation_id: "11111111-1111-4111-8111-111111111111",
duration_ms: 12,
error_code: FAILURE_CODES.REGISTRY_TOOL_FAILURE_UNCLASSIFIED,
ai_client: "other",
ai_client_name: "some-new-tool",
});
expect(result).toMatchObject({
ai_client: "other",
error_code: FAILURE_CODES.REGISTRY_TOOL_FAILURE_UNCLASSIFIED,
});
expect(result).not.toHaveProperty("ai_client_name");
});
});
describe("matches validator", () => {
it("accepts a tool id under 64 chars", () => {
expect(sanitize("tool:invoke", { tool: "gesture-tap", platform: "ios" })).toEqual({
tool: "gesture-tap",
platform: "ios",
});
});
it("accepts a snake_case tool id (e.g. argent-lens tools)", () => {
expect(sanitize("tool:invoke", { tool: "await_user_selection", platform: "ios" })).toEqual({
tool: "await_user_selection",
platform: "ios",
});
});
it("rejects a tool id with uppercase / weird chars", () => {
expect(sanitize("tool:invoke", { tool: "Gesture-Tap!", platform: "ios" })).toEqual({
platform: "ios",
});
});
it("rejects an oversize string even when shape matches", () => {
const longButValid = "a".repeat(200);
expect(sanitize("tool:invoke", { tool: longButValid, platform: "ios" })).toEqual({
platform: "ios",
});
});
it("drops error metadata fields from tool failures", () => {
expect(
sanitize("tool:fail", {
tool: "gesture-tap",
platform: "ios",
duration_ms: 1,
error_message: "ENOENT /Users/alice/.ssh/id_rsa",
stack: "Error: ENOENT\n at /Users/alice/project/app.ts:1",
})
).toEqual({
tool: "gesture-tap",
platform: "ios",
duration_ms: 1,
});
});
it("allows static failure signal fields on tool failures", () => {
expect(
sanitize("tool:fail", {
tool: "gesture-tap",
platform: "ios",
duration_ms: 1,
error_code: "HTTP_ZOD_VALIDATION_FAILED",
failure_stage: "http_zod_validation",
failure_area: "http",
error_kind: "validation",
})
).toEqual({
tool: "gesture-tap",
platform: "ios",
duration_ms: 1,
error_code: "HTTP_ZOD_VALIDATION_FAILED",
failure_stage: "http_zod_validation",
failure_area: "http",
error_kind: "validation",
});
});
it("allows coarse subprocess and network failure metadata", () => {
expect(
sanitize("tool:fail", {
tool: "gesture-tap",
platform: "ios",
duration_ms: 1,
error_code: "ANDROID_ADB_COMMAND_FAILED",
failure_stage: "android_adb_command",
failure_area: "tool_server",
error_kind: "subprocess",
failure_command: "adb",
failure_exit_code: 1,
failure_signal: "SIGKILL",
failure_spawn_code: "ENOENT",
network_failure: "connection_refused",
})
).toEqual({
tool: "gesture-tap",
platform: "ios",
duration_ms: 1,
error_code: "ANDROID_ADB_COMMAND_FAILED",
failure_stage: "android_adb_command",
failure_area: "tool_server",
error_kind: "subprocess",
failure_command: "adb",
failure_exit_code: 1,
failure_signal: "SIGKILL",
failure_spawn_code: "ENOENT",
network_failure: "connection_refused",
});
});
it("rejects unsafe subprocess and network metadata", () => {
expect(
sanitize("tool:fail", {
tool: "gesture-tap",
platform: "ios",
duration_ms: 1,
failure_command: "adb -s /Users/alice/device shell secret",
failure_exit_code: 999,
failure_signal: "SIGUSR1",
failure_spawn_code: "ESECRET",
network_failure: "https://internal.example",
})
).toEqual({
tool: "gesture-tap",
platform: "ios",
duration_ms: 1,
});
});
it("accepts every centrally registered failure code", () => {
for (const errorCode of Object.values(FAILURE_CODES)) {
expect(
sanitize("tool:fail", {
tool: "gesture-tap",
platform: "ios",
duration_ms: 1,
error_code: errorCode,
})
).toMatchObject({ error_code: errorCode });
}
});
it("rejects non-static-looking failure signals", () => {
expect(
sanitize("tool:fail", {
tool: "gesture-tap",
platform: "ios",
duration_ms: 1,
error_code: "ENOENT /Users/alice/.ssh/id_rsa",
failure_stage: "../secret",
failure_area: "laptop",
error_kind: "password",
})
).toEqual({
tool: "gesture-tap",
platform: "ios",
duration_ms: 1,
});
});
it("rejects static-looking but unregistered failure codes", () => {
expect(
sanitize("tool:fail", {
tool: "gesture-tap",
platform: "ios",
duration_ms: 1,
error_code: "SOME_NEW_UNREGISTERED_FAILURE",
})
).toEqual({
tool: "gesture-tap",
platform: "ios",
duration_ms: 1,
});
});
it("allows static failure signal fields on CLI run failures", () => {
expect(
sanitize("cli:run_fail", {
tool: "gesture-tap",
duration_ms: 1,
error_code: "CLI_RUN_ARGS_JSON_INVALID",
failure_stage: "cli_run_parse_raw_args",
failure_area: "cli",
error_kind: "validation",
})
).toEqual({
tool: "gesture-tap",
duration_ms: 1,
error_code: "CLI_RUN_ARGS_JSON_INVALID",
failure_stage: "cli_run_parse_raw_args",
failure_area: "cli",
error_kind: "validation",
});
});
it("drops server-only and unsafe fields from CLI run failures", () => {
expect(
sanitize("cli:run_fail", {
tool: "Gesture-Tap!",
duration_ms: 1,
tool_invocation_id: "11111111-1111-4111-8111-111111111111",
platform: "ios",
error_message: "ENOENT /Users/alice/.ssh/id_rsa",
stack: "Error: ENOENT\n at /Users/alice/project/app.ts:1",
error_code: "CLI_RUN_TOOL_CALL_FAILED",
})
).toEqual({
duration_ms: 1,
error_code: "CLI_RUN_TOOL_CALL_FAILED",
});
});
});
describe("number validator", () => {
it.each([
["NaN", Number.NaN],
["+Infinity", Number.POSITIVE_INFINITY],
["-Infinity", Number.NEGATIVE_INFINITY],
["negative", -1],
["over 2^31", 2 ** 31 + 1],
])("drops poison number %s", (_label, value) => {
expect(sanitize("tool:complete", { tool: "x", platform: "ios", duration_ms: value })).toEqual(
{ tool: "x", platform: "ios" }
);
});
it("accepts a normal duration", () => {
expect(sanitize("tool:complete", { tool: "x", platform: "ios", duration_ms: 42.5 })).toEqual({
tool: "x",
platform: "ios",
duration_ms: 42.5,
});
});
it("accepts valid tool invocation ids and drops invalid ones", () => {
expect(
sanitize("tool:complete", {
tool: "x",
tool_invocation_id: "11111111-1111-4111-8111-111111111111",
platform: "ios",
duration_ms: 42.5,
})
).toEqual({
tool: "x",
tool_invocation_id: "11111111-1111-4111-8111-111111111111",
platform: "ios",
duration_ms: 42.5,
});
expect(
sanitize("tool:complete", {
tool: "x",
tool_invocation_id: "/Users/alice/project",
platform: "ios",
duration_ms: 42.5,
})
).toEqual({
tool: "x",
platform: "ios",
duration_ms: 42.5,
});
});
});
it("passes the 'deferred' stop reason through", () => {
// Emitted by a redundant tool-server instance that lost the port bind to a
// healthy peer and exited cleanly; must not be scrubbed to undefined.
expect(
sanitize("toolserver:stop", {
reason: "deferred",
uptime_ms: 950,
total_tool_calls: 0,
})
).toEqual({
reason: "deferred",
uptime_ms: 950,
total_tool_calls: 0,
});
});
it("allows static failure signal fields on crash stop events", () => {
expect(
sanitize("toolserver:stop", {
reason: "crash",
uptime_ms: 10,
total_tool_calls: 2,
error_code: "TOOLSERVER_UNCAUGHT_EXCEPTION",
failure_stage: "toolserver_uncaught_exception",
failure_area: "tool_server",
error_kind: "crash",
stack: "Error: secret at /Users/alice/project/app.ts:1",
})
).toEqual({
reason: "crash",
uptime_ms: 10,
total_tool_calls: 2,
error_code: "TOOLSERVER_UNCAUGHT_EXCEPTION",
failure_stage: "toolserver_uncaught_exception",
failure_area: "tool_server",
error_kind: "crash",
});
});
describe("crash diagnostics on stop events", () => {
it("passes the coded crash fields through when well-formed", () => {
expect(
sanitize("toolserver:stop", {
reason: "crash",
uptime_ms: 480,
total_tool_calls: 0,
error_code: "TOOLSERVER_UNCAUGHT_EXCEPTION",
failure_stage: "toolserver_uncaught_exception",
failure_area: "tool_server",
error_kind: "crash",
error_name: "TypeError",
error_syscall: "EADDRINUSE",
crash_fingerprint: "0123456789abcdef",
crash_phase: "startup",
})
).toEqual({
reason: "crash",
uptime_ms: 480,
total_tool_calls: 0,
error_code: "TOOLSERVER_UNCAUGHT_EXCEPTION",
failure_stage: "toolserver_uncaught_exception",
failure_area: "tool_server",
error_kind: "crash",
error_name: "TypeError",
error_syscall: "EADDRINUSE",
crash_fingerprint: "0123456789abcdef",
crash_phase: "startup",
});
});
it("accepts ERR_*-style Node error codes as a syscall", () => {
const out = sanitize("toolserver:stop", {
reason: "crash",
uptime_ms: 1,
total_tool_calls: 0,
error_syscall: "ERR_MODULE_NOT_FOUND",
});
expect(out).toMatchObject({ error_syscall: "ERR_MODULE_NOT_FOUND" });
});
it("drops a message masquerading as error_name (spaces / punctuation / path)", () => {
const out = sanitize("toolserver:stop", {
reason: "crash",
uptime_ms: 1,
total_tool_calls: 0,
error_name: "Error: ENOENT /Users/alice/.ssh/id_rsa",
});
expect(out).not.toHaveProperty("error_name");
});
it("drops a path-like or lowercase error_syscall", () => {
for (const bad of ["/Users/alice/x", "eaddrinuse", "listen 127.0.0.1:3001", "PORT_9229"]) {
const out = sanitize("toolserver:stop", {
reason: "crash",
uptime_ms: 1,
total_tool_calls: 0,
error_syscall: bad,
});
expect(out).not.toHaveProperty("error_syscall");
}
});
it("drops a fingerprint that is not exactly 16 lowercase hex chars", () => {
for (const bad of ["0123456789ABCDEF", "0123456789abcde", "0123456789abcdef0", "zzzz", ""]) {
const out = sanitize("toolserver:stop", {
reason: "crash",
uptime_ms: 1,
total_tool_calls: 0,
crash_fingerprint: bad,
});
expect(out).not.toHaveProperty("crash_fingerprint");
}
});
it("drops an unknown crash_phase", () => {
const out = sanitize("toolserver:stop", {
reason: "crash",
uptime_ms: 1,
total_tool_calls: 0,
crash_phase: "booting",
});
expect(out).not.toHaveProperty("crash_phase");
});
});
describe("package action telemetry", () => {
it("accepts the requested package-action enum set", () => {
for (const action of [
"fresh_install",
"already_installed",
"init_triggered_update",
"no_update",
"update_skipped",
"update_failed",
"standalone_update",
"standalone_install",
"mcp_update",
] as const) {
expect(
sanitize("installation:package_action", {
trigger: action === "mcp_update" ? "mcp_update" : "init",
action,
is_success: true,
duration_ms: 2,
})
).toMatchObject({ action, duration_ms: 2 });
}
});
});
describe("arrayOf validator", () => {
it("accepts a valid editors list", () => {
expect(
sanitize("installation:editors_select", {
editors: ["cursor", "claude-code"],
detected_editor_count: 2,
scope: "local",
})
).toEqual({
editors: ["cursor", "claude-code"],
detected_editor_count: 2,
scope: "local",
});
});
it("drops an array with a bad element (whole array discarded)", () => {
expect(
sanitize("installation:editors_select", {
editors: ["cursor", "Bad Editor"],
detected_editor_count: 2,
scope: "local",
})
).toEqual({ detected_editor_count: 2, scope: "local" });
});
it("drops an oversized array (>16 elements)", () => {
const editors = Array.from({ length: 17 }, (_, i) => `editor-${i}`);
expect(
sanitize("installation:editors_select", {
editors,
detected_editor_count: 17,
scope: "local",
})
).toEqual({ detected_editor_count: 17, scope: "local" });
});
});
describe("sensitive-arg drop tests", () => {
it.each([
["keyboard.text", { text: "hunter2" }],
["paste.text", { text: "secret" }],
["open-url.url", { url: "https://internal.example/admin" }],
["flow-add-step.args", { args: '{"text":"private"}' }],
["flow-add-echo.message", { message: "personal data" }],
["customRoot", { customRoot: "/Users/alice/work" }],
["fromTar", { from: "/Users/alice/file.tgz" }],
["bundle_id_hash", { bundle_id_hash: "0123456789ab" }],
["device_id_hash", { device_id_hash: "0123456789ab" }],
])("drops %s when accidentally passed to a tool event", (_label, payload) => {
const out = sanitize("tool:invoke", { tool: "x", platform: "ios", ...payload });
expect(out).toEqual({ tool: "x", platform: "ios" });
});
it("drops accidental error metadata", () => {
const out = sanitize("tool:fail", {
tool: "x",
platform: "ios",
duration_ms: 1,
error_message: "password=hunter2",
});
expect(out).toEqual({ tool: "x", platform: "ios", duration_ms: 1 });
});
});
describe("lens telemetry events", () => {
it("keeps the round_completed usage flags and drops leaked content", () => {
const out = sanitize("lens:round_completed", {
round: 2,
element_count: 3,
variant_count: 5,
annotation_count: 1,
element_comment_count: 2,
skipped_comment_count: 1,
has_global_comment: true,
inspector_used: true,
offscreen_revealed: false,
is_cli_session: true,
had_parked_await: false,
round_duration_ms: 1234,
platform: "ios",
// Content that must never survive:
element_name: "Checkout button",
comment_text: "make it pop",
});
expect(out).toEqual({
round: 2,
element_count: 3,
variant_count: 5,
annotation_count: 1,
element_comment_count: 2,
skipped_comment_count: 1,
has_global_comment: true,
inspector_used: true,
offscreen_revealed: false,
is_cli_session: true,
had_parked_await: false,
round_duration_ms: 1234,
platform: "ios",
});
});
it("drops a non-boolean inspector_used / offscreen_revealed", () => {
const out = sanitize("lens:round_completed", {
round: 1,
inspector_used: "yes",
offscreen_revealed: 1,
});
// The bool validator rejects non-booleans, so both keys are removed.
expect(out).toEqual({ round: 1 });
});
it("keeps agent_choice_count on cli_session_started and drops extras", () => {
const out = sanitize("lens:cli_session_started", {
agent_choice_count: 2,
agent_names: ["claude", "cursor"], // must be dropped
});
expect(out).toEqual({ agent_choice_count: 2 });
});
});
describe("ALLOWED ↔ EVENT_NAMES sync", () => {
it("every declared event name has a sanitizer entry", () => {
for (const name of EVENT_NAMES) {
expect(ALLOWED[name]).toBeDefined();
}
});
it("every sanitizer entry is a declared event name", () => {
for (const name of Object.keys(ALLOWED)) {
expect(EVENT_NAMES).toContain(name);
}
});
});
});