mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
8483fe23f9
## Summary Adds anonymous, opt-out telemetry for Argent installer, update/uninstall flows, CLI telemetry controls, tool-server lifecycle, and tool invocation health signals. ## What Changed - Added a new private `@argent/telemetry` package - Introduced typed telemetry events with runtime property allowlisting/sanitization. - Added lazy anonymous identity creation in `~/.argent/telemetry-id`. - Added consent handling via: `argent telemetry enable`, `argent telemetry disable`, `argent telemetry status` - Added local debug logging with `ARGENT_TELEMETRY_DEBUG=1`. - Tracks installer funnel events for init, global install/update decisions, editor selection, allowlist decisions, skill installation, rules/agents copy, update, and uninstall. - Tracks tool-server start/stop and tool invocation/complete/fail events. - Adds per-tool invocation IDs so request metadata can be correlated without relying on ordering. - Records only coarse platform metadata (`ios` / `android`) for device tools - Ensures short-lived commands drain telemetry with bounded shutdown before exit. - Wires telemetry package into bundling and injects full Argent CLI version metadata at build time. - Added central `FAILURE_CODES` and `FailureSignal` helpers in `@argent/registry`. - Attached structured, sanitized failure metadata across registry errors, tool-server tools/services, HTTP validation/dependency gates, installer flows, and `argent run`. - Added telemetry allowlisting for safe failure fields only: static error codes/stages, coarse area/kind/command, bounded exit codes, allowlisted signals/spawn codes, and coarse network failure classes. - Split CLI-local `argent run` failures into `cli:run_fail` so they are not double-counted as tool failures. - Added installer telemetry for failed package actions, update/uninstall failures, rules/agents copy results, and skill refresh results. - Made telemetry consent writes atomic and kept telemetry status side-effect free via `peekAnonId()`. - Adds a coarse, anonymous signal to tool telemetry: which AI coding tool is driving the MCP server (Claude Code, Codex, Cursor, Gemini, VS Code, Windsurf, Zed, opencode, Copilot, or other). ## Tests Added / Updated - Telemetry unit coverage for consent precedence, identity creation, base props, sanitization, PostHog config, debug logging, shutdown, opt-out, and forget behavior. - Registry/tool-server tests for invocation IDs, platform metadata boundaries, startup failure flushing, and tool lifecycle telemetry. - Installer tests for final telemetry drain, update action classification, and uninstall identity cleanup ordering. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
126 lines
4.5 KiB
TypeScript
126 lines
4.5 KiB
TypeScript
import * as fs from "node:fs";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { scopeHome } from "./helpers.js";
|
|
import { readOrCreateAnonId, deleteAnonId } from "../src/identity.js";
|
|
import { identityFilePath } from "../src/paths.js";
|
|
|
|
describe("identity", () => {
|
|
const { tmp } = scopeHome();
|
|
|
|
it("creates a UUID on first call and reuses it on second", () => {
|
|
const first = readOrCreateAnonId();
|
|
expect(first).toMatch(/^[0-9a-f-]{36}$/);
|
|
const second = readOrCreateAnonId();
|
|
expect(second).toBe(first);
|
|
});
|
|
|
|
it("writes with mode 0600", () => {
|
|
readOrCreateAnonId();
|
|
const stats = fs.lstatSync(identityFilePath());
|
|
expect(stats.mode & 0o777).toBe(0o600);
|
|
});
|
|
|
|
it("survives a half-written tmpfile", () => {
|
|
readOrCreateAnonId();
|
|
// Plant a leftover tmpfile to simulate a crash mid-create. The next
|
|
// read still succeeds because the FINAL path was written cleanly.
|
|
fs.writeFileSync(tmp() + "/.argent/.telemetry-id.tmp.99999.deadbeef", "half-written-garbage");
|
|
const id = readOrCreateAnonId();
|
|
expect(id).toMatch(/^[0-9a-f-]{36}$/);
|
|
});
|
|
|
|
it("cleans up the temp file when writing the id fails", async () => {
|
|
vi.resetModules();
|
|
vi.doMock("node:fs", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("node:fs")>();
|
|
return {
|
|
...actual,
|
|
// Fail the durability sync after the temp file is opened + written, so
|
|
// the create path throws between openSync and the link/publish step.
|
|
fsyncSync: vi.fn(() => {
|
|
const err = new Error("simulated I/O failure") as NodeJS.ErrnoException;
|
|
err.code = "EIO";
|
|
throw err;
|
|
}),
|
|
};
|
|
});
|
|
|
|
try {
|
|
const { readOrCreateAnonId } = await import("../src/identity.js");
|
|
expect(() => readOrCreateAnonId()).toThrow();
|
|
|
|
// No id was published, and no `.telemetry-id.tmp.*` orphan was left behind.
|
|
expect(fs.existsSync(identityFilePath())).toBe(false);
|
|
const leftovers = fs
|
|
.readdirSync(tmp() + "/.argent")
|
|
.filter((name) => name.startsWith(".telemetry-id.tmp."));
|
|
expect(leftovers).toEqual([]);
|
|
} finally {
|
|
vi.doUnmock("node:fs");
|
|
vi.resetModules();
|
|
}
|
|
});
|
|
|
|
it("fails closed when a symlink is planted at the final path", () => {
|
|
// First create a real file elsewhere.
|
|
fs.mkdirSync(tmp() + "/.argent", { recursive: true });
|
|
const evilTarget = tmp() + "/evil.txt";
|
|
fs.writeFileSync(evilTarget, "00000000-0000-0000-0000-000000000000");
|
|
fs.symlinkSync(evilTarget, identityFilePath());
|
|
|
|
// The symlink should NOT be honoured or replaced.
|
|
expect(() => readOrCreateAnonId()).toThrow();
|
|
expect(fs.lstatSync(identityFilePath()).isSymbolicLink()).toBe(true);
|
|
});
|
|
|
|
it("deleteAnonId removes the file and is idempotent", () => {
|
|
readOrCreateAnonId();
|
|
expect(fs.existsSync(identityFilePath())).toBe(true);
|
|
deleteAnonId();
|
|
expect(fs.existsSync(identityFilePath())).toBe(false);
|
|
expect(() => deleteAnonId()).not.toThrow();
|
|
});
|
|
|
|
it("two concurrent createOrRead calls converge on one UUID", async () => {
|
|
// Race two creates from the same process; the final-path link() gate
|
|
// guarantees the loser reads the winner's file.
|
|
const [a, b] = await Promise.all([
|
|
Promise.resolve().then(() => readOrCreateAnonId()),
|
|
Promise.resolve().then(() => readOrCreateAnonId()),
|
|
]);
|
|
expect(a).toBe(b);
|
|
});
|
|
|
|
it("does not overwrite an id published between temp write and final publish", async () => {
|
|
const winner = "11111111-1111-4111-8111-111111111111";
|
|
let planted = false;
|
|
|
|
vi.resetModules();
|
|
vi.doMock("node:fs", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("node:fs")>();
|
|
return {
|
|
...actual,
|
|
linkSync: vi.fn((existingPath: fs.PathLike, newPath: fs.PathLike) => {
|
|
if (!planted && String(newPath) === identityFilePath()) {
|
|
planted = true;
|
|
actual.writeFileSync(identityFilePath(), winner, { mode: 0o600 });
|
|
const err = new Error("file exists") as NodeJS.ErrnoException;
|
|
err.code = "EEXIST";
|
|
throw err;
|
|
}
|
|
return actual.linkSync(existingPath, newPath);
|
|
}),
|
|
};
|
|
});
|
|
|
|
try {
|
|
const { readOrCreateAnonId } = await import("../src/identity.js");
|
|
expect(readOrCreateAnonId()).toBe(winner);
|
|
expect(fs.readFileSync(identityFilePath(), "utf8")).toBe(winner);
|
|
} finally {
|
|
vi.doUnmock("node:fs");
|
|
vi.resetModules();
|
|
}
|
|
});
|
|
});
|