From 27443f62989bd2675c77ed54ee0cff270dad45a2 Mon Sep 17 00:00:00 2001 From: Ander Date: Tue, 6 Oct 2026 21:32:48 +0200 Subject: [PATCH] fix(telemetry): the agent id was per project folder, so one machine looked like a new device in every folder X-ZR-Agent-Id lived in .zenrows/config.json, so each new folder minted a new id, and a retry from a machine that had already signed up could not be tied back to it. Store the id per machine (ZENROWS_CONFIG_HOME, XDG_CONFIG_HOME/zenrows, ~/.config/zenrows, or %APPDATA%\zenrows), adopt an existing workspace id on first run, and fall back to the workspace when the machine directory is not writable. Co-Authored-By: Claude Opus 5.5 --- src/core/config.ts | 47 +++++++++++++++++++++--- src/types/index.ts | 3 +- tests/setup.ts | 5 +++ tests/telemetry-id.test.ts | 74 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 tests/telemetry-id.test.ts diff --git a/src/core/config.ts b/src/core/config.ts index 72df141..164d706 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -2,6 +2,9 @@ * Toolkit config (`.zenrows/config.json`). Non-secret, safe defaults. */ import { randomUUID } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; import type { ToolkitConfig } from "../types/index.ts"; import { findWorkspace, readJson, workspacePaths, writeJson } from "./workspace.ts"; @@ -67,20 +70,56 @@ export function saveConfig(config: ToolkitConfig, projectRoot?: string): void { writeJson(ws.config, config); } +/** Env var to relocate the per-machine config directory (tests, sandboxes). */ +export const CONFIG_HOME_ENV = "ZENROWS_CONFIG_HOME"; + +/** Per-machine config directory, shared by every workspace on this machine. */ +export function machineConfigDir(): string { + const override = process.env[CONFIG_HOME_ENV]; + if (override && override.trim()) return override.trim(); + if (process.platform === "win32" && process.env.APPDATA) return join(process.env.APPDATA, "zenrows"); + const xdg = process.env.XDG_CONFIG_HOME; + return join(xdg && xdg.trim() ? xdg.trim() : join(homedir(), ".config"), "zenrows"); +} + +function readMachineTelemetryId(file: string): string | undefined { + try { + const id = readFileSync(file, "utf8").trim(); + return id || undefined; + } catch { + return undefined; + } +} + /** * Return the stable anonymous agent id, generating + persisting one on first * use. Sent as the `X-ZR-Agent-Id` header on signup so the backend can correlate * the (anonymous) device with the account it later merges on claim. Contains no * PII — a random uuid only. + * + * Stored per machine, not per workspace: a per-workspace id made every new + * project folder look like a new device, so the backend could not tell a + * rate-limited retry from the machine that had just signed up. An id already in + * the workspace is adopted on first run so existing devices keep their identity. + * If the machine directory is not writable, falls back to the workspace config. */ export function getOrCreateTelemetryId(projectRoot?: string): string { + const file = join(machineConfigDir(), "telemetry-id"); + const machineId = readMachineTelemetryId(file); + if (machineId) return machineId; + const ws = projectRoot ? workspacePaths(projectRoot) : (findWorkspace() ?? workspacePaths()); // Merge onto the raw stored config (not loadConfig) so we never persist the // ZENROWS_API_BASE env override back into config.json. const stored = readJson>(ws.config) ?? {}; - const existing = stored.telemetryId; - if (existing && existing.trim()) return existing; - const id = randomUUID(); - saveConfig({ ...defaultConfig(), ...stored, telemetryId: id }, projectRoot); + const workspaceId = stored.telemetryId?.trim() ? stored.telemetryId.trim() : undefined; + const id = workspaceId ?? randomUUID(); + + try { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, `${id}\n`, { mode: 0o600 }); + } catch { + if (!workspaceId) saveConfig({ ...defaultConfig(), ...stored, telemetryId: id }, projectRoot); + } return id; } diff --git a/src/types/index.ts b/src/types/index.ts index 3eea0a4..b6b4fd5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -81,7 +81,8 @@ export interface ToolkitConfig { */ discoveryUrl?: string; /** - * Stable, anonymous agent id (uuid). Generated once and persisted here; sent + * Legacy per-workspace agent id (uuid), adopted into the per-machine id on + * first run (see `getOrCreateTelemetryId`). Sent * as the `X-ZR-Agent-Id` signup header so the backend can correlate the * anonymous device with the account it merges on claim. No PII — random only. */ diff --git a/tests/setup.ts b/tests/setup.ts index 35d4881..63e3e73 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -9,11 +9,16 @@ * each test-file worker before any test module loads. Tests that need a * specific override set it explicitly (and restore it) themselves. */ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { AGENT_ENV_VARS } from "../src/core/agent-client.ts"; for (const key of Object.keys(process.env)) { if (key.startsWith("ZENROWS_")) delete process.env[key]; } +// Keep the per-machine telemetry id out of the developer's real home directory. +process.env.ZENROWS_CONFIG_HOME = mkdtempSync(join(tmpdir(), "zr-config-home-")); // Running the suite from inside an AI agent (CLAUDE_CODE_CHILD_SESSION=1 in // Claude Code's shell) would otherwise add X-ZenRows-Client to every request. for (const key of AGENT_ENV_VARS) delete process.env[key]; diff --git a/tests/telemetry-id.test.ts b/tests/telemetry-id.test.ts new file mode 100644 index 0000000..bcda83b --- /dev/null +++ b/tests/telemetry-id.test.ts @@ -0,0 +1,74 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CONFIG_HOME_ENV, defaultConfig, getOrCreateTelemetryId, saveConfig } from "../src/core/config.ts"; +import { createWorkspace } from "../src/core/workspace.ts"; +import { tempRoot } from "./helpers.ts"; + +function withConfigHome(fn: (home: string) => void): void { + const previous = process.env[CONFIG_HOME_ENV]; + const home = mkdtempSync(join(tmpdir(), "zr-home-")); + process.env[CONFIG_HOME_ENV] = home; + try { + fn(home); + } finally { + if (previous === undefined) delete process.env[CONFIG_HOME_ENV]; + else process.env[CONFIG_HOME_ENV] = previous; + rmSync(home, { recursive: true, force: true }); + } +} + +test("two workspaces on the same machine send the same agent id", () => { + withConfigHome((home) => { + const a = tempRoot(); + const b = tempRoot(); + try { + createWorkspace(a.root); + createWorkspace(b.root); + const first = getOrCreateTelemetryId(a.root); + const second = getOrCreateTelemetryId(b.root); + assert.match(first, /^[0-9a-f-]{36}$/); + assert.equal(second, first); + assert.equal(readFileSync(join(home, "telemetry-id"), "utf8").trim(), first); + } finally { + a.cleanup(); + b.cleanup(); + } + }); +}); + +test("an id already stored in a workspace is adopted as the machine id", () => { + withConfigHome(() => { + const legacy = tempRoot(); + const fresh = tempRoot(); + try { + createWorkspace(legacy.root); + createWorkspace(fresh.root); + saveConfig({ ...defaultConfig(), telemetryId: "legacy-workspace-id" }, legacy.root); + assert.equal(getOrCreateTelemetryId(legacy.root), "legacy-workspace-id"); + assert.equal(getOrCreateTelemetryId(fresh.root), "legacy-workspace-id"); + } finally { + legacy.cleanup(); + fresh.cleanup(); + } + }); +}); + +test("falls back to the workspace when the machine directory is not writable", () => { + withConfigHome((home) => { + // A file where the directory should be makes mkdir/write fail on every platform. + const blocked = join(home, "blocked"); + writeFileSync(blocked, ""); + process.env[CONFIG_HOME_ENV] = join(blocked, "zenrows"); + const ws = tempRoot(); + try { + createWorkspace(ws.root); + const id = getOrCreateTelemetryId(ws.root); + assert.equal(getOrCreateTelemetryId(ws.root), id); + } finally { + ws.cleanup(); + } + }); +});