Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 43 additions & 4 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<Partial<ToolkitConfig>>(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;
}
3 changes: 2 additions & 1 deletion src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
5 changes: 5 additions & 0 deletions tests/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
74 changes: 74 additions & 0 deletions tests/telemetry-id.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
Loading