mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
c573e9c524
## Why Telemetry cannot tell a physical iPhone from a simulator: `platform` is `ios` for both, and the tool-server's `kind` never reaches the events. Physical-iOS support shipped in 0.24.0 with no way to count its adoption except through `ios_device_*` failure stages on `tool:fail`. Physical Android phones have the same gap. ## What `tool:invoke` / `tool:complete` / `tool:fail` gain `device_kind: simulator | emulator | vvd | device | app`, derived from the same device id as `platform` (single classifier in `telemetry-platform.ts`, which `debugger:tool_outcome` and `lens:*` also go through for their platform). `platform: ios` + `device_kind: device` is a physical iPhone. The kind is emitted only for positively recognised id shapes, so the metric is not polluted by ids that are not devices: | id shape | platform | device_kind | |---|---|---| | simulator UUID / `remote:` | ios / ios-remote (tvos when the cache is warm) | simulator | | physical iPhone UDID | ios | device | | `emulator-NNNN` | android (android-tv when warm) | emulator | | USB serial, 6–20 alnum with a digit and a letter | android | device | | non-loopback `ip:port` | android | device (best effort) | | loopback `127.0.0.1:port` / `localhost:port` / `::1:port` | android | emulator | | anything else non-iOS-shaped (Metro 40-hex handle, `booted`, `Pixel_7`, IPv6, mDNS) | android | omitted | | `chromium-cdp-<port>` / `amazon-…` | chromium / vega | app / vvd | | `avdName`-only boot | android | omitted | A child sub-tool that names its own device replaces the parent's platform, kind and provider label wholesale, so an `avdName`-only child never reports the parent's `simulator` next to its own `android`. A logical-keyed Metro session strips the kind. `unknown` (in the registry `DeviceKind`) is rejected by the sanitizer like `platform: "unknown"`; no registry change. ## Querying - Physical iPhone adoption: `tool:invoke` where `platform = 'ios' and device_kind = 'device'`, distinct users over it. `isIosPhysicalUdid` is a positive match, so invoke is clean. - Physical Android: count on `tool:complete` (the device answered). `tool:invoke` / `tool:fail` still carry residual mistakes that happen to look like a serial (`Pixel7`), and a non-loopback `ip:port` may be a networked virtual device. ## Docs - `Telemetry.md` → 1.04, effective date 7 September 2026 (adjust to the release date on merge). Discloses the new category and, while touching that section, the external-provider label that #735 added without a notice line — happy to drop that second bullet if it should ship separately. - No docs-site change: `docs/reference/telemetry.mdx` links to `Telemetry.md` and has no per-property list. ## Notes - Pre-existing, unchanged: HTTP-edge failures carry no `tool_invocation_id`; `stop-all-simulator-servers` declares no capability, so its invoke row has neither field while its fail row has both. `device_kind` follows `platform` in each site. - Legacy 40-hex iPhone UDIDs already classify as `android` today; they now get no kind rather than a wrong one. - Design went through three adversarial review rounds before implementation and one on the diff; the fallback-bucket and child-attribution rules above came out of those. ## Verification - `packages/telemetry`: 319 tests, `packages/tool-server`: full suite 5670 passed / 1 skipped; `typecheck:tests` both packages; `eslint --max-warnings 0`; `knip`; prettier. - New: `test/telemetry-device-kind.test.ts` (the shape table above), plus cases in `http-tools-meta`, `http-platform-alias`, `sanitize`, `registry-listener`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BSGpeAwvoCv2JAhbLsuto2 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Telemetry now records a device category—such as simulator, emulator, physical device, virtual device, or desktop app—alongside the platform. * Device categories are inferred from device information and may include a short label for external device providers. * Device category information is included in tool invocation, completion, and failure telemetry when available. * Device identifiers themselves continue to be excluded from telemetry. * **Documentation** * Updated the Argent Privacy Notice (Telemetry) to version 1.04, effective 7 September 2026. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
857 lines
28 KiB
TypeScript
857 lines
28 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { FAILURE_CODES } from "@argent/registry";
|
|
import { sanitize, ALLOWED } from "../src/sanitize.js";
|
|
import { DEBUGGER_TOOL_OUTCOMES, DEVICE_KINDS, 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 the collector.
|
|
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("accepts every device kind on the tool lifecycle events", () => {
|
|
// `device_kind` rides next to `platform`; `platform: "ios"` + `device_kind:
|
|
// "device"` is how a physical iPhone is told apart from a simulator.
|
|
for (const event of ["tool:invoke", "tool:complete", "tool:fail"] as const) {
|
|
for (const device_kind of DEVICE_KINDS) {
|
|
expect(sanitize(event, { tool: "describe", platform: "ios", device_kind })).toEqual({
|
|
tool: "describe",
|
|
platform: "ios",
|
|
device_kind,
|
|
});
|
|
}
|
|
}
|
|
expect(DEVICE_KINDS).toContain("device");
|
|
});
|
|
|
|
it("drops a device kind outside the enum, including the registry's `unknown`", () => {
|
|
// Mirror of the `platform: "unknown"` rule above: `resolveDevice` never
|
|
// produces `unknown`, so a bucket for it could only be filled by a bug.
|
|
for (const device_kind of ["unknown", "iphone", "Simulator", 1, null]) {
|
|
expect(sanitize("tool:invoke", { tool: "describe", platform: "ios", device_kind })).toEqual(
|
|
{ tool: "describe", platform: "ios" }
|
|
);
|
|
}
|
|
});
|
|
|
|
it("drops device_kind on events that do not declare it", () => {
|
|
expect(
|
|
sanitize("debugger:tool_outcome", {
|
|
tool: "debugger-status",
|
|
outcome: "connected",
|
|
platform: "ios",
|
|
device_kind: "device",
|
|
})
|
|
).toEqual({ tool: "debugger-status", outcome: "connected", platform: "ios" });
|
|
});
|
|
|
|
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("tool:fail invalid_params validator", () => {
|
|
const base = { tool: "launch-app", duration_ms: 1 };
|
|
|
|
it("accepts camelCase schema names — the /i flag is load-bearing", () => {
|
|
// A third of registered tools declare camelCase zod keys (bundleId,
|
|
// avdName, maxNodes, ...). arrayOf is all-or-nothing, so dropping the
|
|
// case-insensitive flag would silently void the whole property for any
|
|
// call whose failing param is camelCase.
|
|
const out = sanitize("tool:fail", {
|
|
...base,
|
|
invalid_params: ["bundleId", "electronAppPath", "unrecognized_keys"],
|
|
});
|
|
expect(out.invalid_params).toEqual(["bundleId", "electronAppPath", "unrecognized_keys"]);
|
|
});
|
|
|
|
it("accepts exactly 16 names — the emit-side cap must fit through", () => {
|
|
// http.ts caps the derived list at 16 before emitting; the sanitize cap
|
|
// must not be tighter, or every capped emission silently loses the
|
|
// property.
|
|
const names = Array.from({ length: 16 }, (_, i) => `param_${i}`);
|
|
const out = sanitize("tool:fail", { ...base, invalid_params: names });
|
|
expect(out.invalid_params).toEqual(names);
|
|
});
|
|
|
|
it("drops the whole property at 17 names", () => {
|
|
const names = Array.from({ length: 17 }, (_, i) => `param_${i}`);
|
|
const out = sanitize("tool:fail", { ...base, invalid_params: names });
|
|
expect("invalid_params" in out).toBe(false);
|
|
});
|
|
|
|
it("drops the whole property when one element is not an identifier", () => {
|
|
const out = sanitize("tool:fail", {
|
|
...base,
|
|
invalid_params: ["port", "not a name!"],
|
|
});
|
|
expect("invalid_params" in out).toBe(false);
|
|
});
|
|
|
|
it("drops an element longer than 64 characters (whole property voided)", () => {
|
|
const out = sanitize("tool:fail", {
|
|
...base,
|
|
invalid_params: ["a".repeat(65)],
|
|
});
|
|
expect("invalid_params" in out).toBe(false);
|
|
});
|
|
});
|
|
|
|
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("tool:fail invalid_params", () => {
|
|
const base = { tool: "debugger-status", platform: "ios", duration_ms: 5 };
|
|
|
|
it("keeps a conforming list of schema-declared names (incl. the unrecognized_keys token)", () => {
|
|
const out = sanitize("tool:fail", {
|
|
...base,
|
|
invalid_params: ["device_id", "port", "unrecognized_keys"],
|
|
});
|
|
expect(out).toEqual({
|
|
...base,
|
|
invalid_params: ["device_id", "port", "unrecognized_keys"],
|
|
});
|
|
});
|
|
|
|
it("voids the whole array when longer than 16 (the emit side must cap first)", () => {
|
|
const out = sanitize("tool:fail", {
|
|
...base,
|
|
invalid_params: Array.from({ length: 17 }, (_, i) => `p${i}`),
|
|
});
|
|
expect(out).toEqual(base);
|
|
});
|
|
|
|
it("voids the whole array when any element is not an identifier-shaped name", () => {
|
|
// A leaked value (spaces, path chars) must take the entire property down,
|
|
// not slip through next to valid names.
|
|
const out = sanitize("tool:fail", {
|
|
...base,
|
|
invalid_params: ["port", "/Users/alice/secret file"],
|
|
});
|
|
expect(out).toEqual(base);
|
|
});
|
|
|
|
it("voids a non-array invalid_params", () => {
|
|
const out = sanitize("tool:fail", { ...base, invalid_params: "device_id" });
|
|
expect(out).toEqual(base);
|
|
});
|
|
});
|
|
|
|
describe("debugger:tool_outcome", () => {
|
|
const UUID_V = "a2d4a13a-6c0f-4e0e-8e14-1c8b8f0a1234";
|
|
|
|
it("accepts every declared outcome — including the gate-branch values", () => {
|
|
// stale_connection / reconnecting come from the socket-state gate, not
|
|
// the catch classifier; the enum must admit them at the SANITIZE level or
|
|
// those emissions silently lose their outcome dimension.
|
|
for (const outcome of DEBUGGER_TOOL_OUTCOMES) {
|
|
expect(
|
|
sanitize("debugger:tool_outcome", {
|
|
tool: "debugger-status",
|
|
outcome,
|
|
platform: "android",
|
|
tool_invocation_id: UUID_V,
|
|
})
|
|
).toEqual({
|
|
tool: "debugger-status",
|
|
outcome,
|
|
platform: "android",
|
|
tool_invocation_id: UUID_V,
|
|
});
|
|
}
|
|
expect(DEBUGGER_TOOL_OUTCOMES).toContain("reconnecting");
|
|
expect(DEBUGGER_TOOL_OUTCOMES).toContain("stale_connection");
|
|
expect(DEBUGGER_TOOL_OUTCOMES).toContain("connected");
|
|
});
|
|
|
|
it("accepts both emitting tools and drops any other tool id", () => {
|
|
for (const tool of ["debugger-status", "debugger-log-registry"] as const) {
|
|
expect(sanitize("debugger:tool_outcome", { tool, outcome: "connected" })).toEqual({
|
|
tool,
|
|
outcome: "connected",
|
|
});
|
|
}
|
|
expect(
|
|
sanitize("debugger:tool_outcome", { tool: "gesture-tap", outcome: "connected" })
|
|
).toEqual({ outcome: "connected" });
|
|
});
|
|
|
|
it("drops an unknown outcome value (free-text error strings must never ride this field)", () => {
|
|
expect(
|
|
sanitize("debugger:tool_outcome", {
|
|
tool: "debugger-status",
|
|
outcome: "ECONNREFUSED 127.0.0.1:8081",
|
|
})
|
|
).toEqual({ tool: "debugger-status" });
|
|
});
|
|
|
|
it("drops a malformed tool_invocation_id and unknown extra properties", () => {
|
|
expect(
|
|
sanitize("debugger:tool_outcome", {
|
|
tool: "debugger-log-registry",
|
|
outcome: "metro_not_running",
|
|
tool_invocation_id: "not-a-uuid",
|
|
detail: "Metro at port 8081 is not running",
|
|
guidance: "Do not retry",
|
|
})
|
|
).toEqual({ tool: "debugger-log-registry", outcome: "metro_not_running" });
|
|
});
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|
|
});
|
|
});
|