diff --git a/docs/docs/reference/security-faq.md b/docs/docs/reference/security-faq.md index 71fb872318..5825730d58 100644 --- a/docs/docs/reference/security-faq.md +++ b/docs/docs/reference/security-faq.md @@ -130,8 +130,9 @@ export ALTIMATE_TELEMETRY_DISABLED=true - **Logged-in users:** Your email is SHA-256 hashed before sending. We never see your raw email. - **Anonymous users:** A random UUID (`crypto.randomUUID()`) is generated on first run and stored at `~/.altimate/machine-id`. This is NOT tied to your hardware, OS, or identity — it's purely random. -- **Both identifiers** are only sent when telemetry is enabled. Disable with `ALTIMATE_TELEMETRY_DISABLED=true`. +- **Both identifiers** are only sent when telemetry is enabled. Disable via `ALTIMATE_TELEMETRY_DISABLED=true` or the `telemetry.disabled` config option. - **No fingerprinting:** We do not use browser fingerprinting, hardware IDs, MAC addresses, or IP-based tracking. +- **CLI auth flow:** When you sign in via `altimate auth login`, the anonymous machine ID is included in the authorization URL and associated with your account in product analytics for funnel analysis. This is suppressed when you disable telemetry — via `ALTIMATE_TELEMETRY_DISABLED=true` or the `telemetry.disabled` config option — and the machine ID is omitted from the URL entirely. ### What happens on first launch? diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index 0ef5926aba..c092eeb195 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -149,6 +149,10 @@ Altimate Code uses two types of anonymous identifiers for analytics, depending o Both identifiers are only sent when telemetry is enabled. Disable telemetry entirely with `ALTIMATE_TELEMETRY_DISABLED=true` or the config option above. +### CLI Authentication Flow + +When you sign in using the CLI browser auth flow (`altimate auth login`), the anonymous machine ID (a random UUID persisted at `~/.altimate/machine-id` — a device/installation identifier, reused across sessions) is included in the authorization URL and associated with your account in product analytics. This is used solely to correlate CLI install events with authenticated accounts in aggregate funnel analytics — it is not used for advertising or cross-site tracking. Your telemetry opt-out suppresses this: when you disable telemetry — via `ALTIMATE_TELEMETRY_DISABLED=true` **or** the `telemetry.disabled` config option — the machine ID is omitted from the authorization URL entirely. The machine ID is associated with your account in PostHog for this funnel analysis, separate from the Azure Application Insights pipeline used for other CLI telemetry events. + ### Data Retention Telemetry data is sent to Azure Application Insights and retained according to [Microsoft's data retention policies](https://learn.microsoft.com/en-us/azure/azure-monitor/logs/data-retention-configure). We do not maintain a separate data store. To request deletion of your telemetry data, contact privacy@altimate.ai. diff --git a/packages/opencode/src/altimate/plugin/altimate.ts b/packages/opencode/src/altimate/plugin/altimate.ts index eaa26366a8..b57b4233ef 100644 --- a/packages/opencode/src/altimate/plugin/altimate.ts +++ b/packages/opencode/src/altimate/plugin/altimate.ts @@ -5,6 +5,11 @@ import open from "open" import { AltimateApi } from "../api/client" // altimate_change — onboarding telemetry for the gateway sign-in funnel import * as OnboardingTelemetry from "../telemetry/onboarding" +// altimate_change — shared machine-id helper (race-safe, UUID-validated, size-capped) +import { getOrCreateMachineId } from "../util/machine-id" +import { Config } from "@/config/config" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { Log } from "@/altimate/util/log" /** * Why a failure reason is attached at the rejection site rather than inferred from the message: @@ -47,6 +52,90 @@ const DEFAULT_WEB_URL = "https://app.myaltimate.com" // deliver. const DEFAULT_API_URL = "https://api.myaltimate.com" +const log = Log.create({ service: "altimate-plugin" }) + +// Builds a base64url-encoded context blob for correlating this browser auth +// session with CLI telemetry in PostHog. The machine_id is a random UUID +// stored at ~/.altimate/machine-id — not tied to hardware, OS, or user identity. +// After sign-in the frontend registers it as the `cli_machine_id` PostHog +// super-property so the CLI device is attributed to the authenticated account +// in aggregate funnel analytics. +// +// Privacy note: cli_context is sent in the URL *fragment* (#cli_context=...), +// not the query string. The browser never transmits a fragment to the server, +// so the machine_id — though a non-PII crypto.randomUUID() — stays out of +// app.myaltimate.com's access logs, any fronting CDN/WAF, and the Referer +// header, while remaining readable by the /register page via location.hash. +// +// Frontend decode contract (implemented in monorepo useCliContext.ts / +// cliContext.ts): the value is base64url (not standard base64); the consumer +// must catch decode/JSON errors, require `v === 1`, validate that `machine_id` +// and `cli_version` are strings, treat `cli_version: "local"` as a valid dev +// build, and treat the payload as untrusted (anyone can craft a URL). An absent +// `machine_id` means "do not attribute" — it must never be aliased on. +export async function buildCliContext(machineIdPath?: string): Promise { + // altimate_change start — honour both telemetry opt-out gates, mirroring + // telemetry/index.ts::doInit: + // 1. ALTIMATE_TELEMETRY_DISABLED=true env var (always-works hard opt-out) + // 2. config.telemetry.disabled (resolved via the async Config.get()) + let disabled = process.env.ALTIMATE_TELEMETRY_DISABLED === "true" + if (!disabled) { + try { + const userConfig = (await Config.get()) as any + disabled = Boolean(userConfig.telemetry?.disabled) + } catch (err) { + // Config was unreadable — NOT the normal path. Server routes run inside + // Instance.provide() (AsyncLocalStorage-propagated across awaits), so + // Config.get() resolves during an ordinary browser authorize(). The known + // exception is `altimate auth login `, which deliberately skips + // instance bootstrap (ProvidersLoginCommand `instance: (args) => !args.url`); + // on that path this fires. Fail CLOSED — omit the durable machine_id rather + // than transmit it for a user who may have opted out via config; a missed + // correlation beats leaking a stable identifier. Log so a low correlation + // rate is traceable here instead of being mistaken for a lost reply. + log.warn("cli_context: config unreadable, omitting machine_id (fail-closed)", { + code: (err as NodeJS.ErrnoException)?.code, + }) + disabled = true + } + } + let machineId = "" + if (!disabled) { + // getOrCreateMachineId returns "" on all error conditions (ENOENT excluded — + // it mints a new UUID instead) and logs appropriately; no try/catch needed. + machineId = getOrCreateMachineId(machineIdPath) + } + // altimate_change end + // altimate_change start — omit machine_id when empty (matches the telemetry + // module's `...(machineId && { machine_id })`). An empty value is meaningless + // to the frontend super-property registration and must not be sent. + const ctx: Record = { v: 1, cli_version: InstallationVersion } + if (machineId) ctx.machine_id = machineId + // altimate_change end + return Buffer.from(JSON.stringify(ctx)).toString("base64url") +} + +// altimate_change start — exported so tests can assert on the full URL shape +// without duplicating the construction logic. `machineIdPath` is forwarded to +// buildCliContext so tests can point at a temp file instead of writing a real +// id into the runner's $HOME. +export async function buildAuthorizeUrl( + webUrl: string, + redirect: string, + state: string, + machineIdPath?: string, +): Promise { + return ( + `${webUrl}/register?client=altimate-code` + + `&redirect=${encodeURIComponent(redirect)}` + + `&state=${encodeURIComponent(state)}` + + // Fragment (#), not a query param — keeps the durable machine_id out of + // server access logs / Referer. Must stay last, after all query params. + `#cli_context=${encodeURIComponent(await buildCliContext(machineIdPath))}` + ) +} +// altimate_change end + // The one-time login_token is POSTed to the callback-supplied API base, so that // base must be trusted — otherwise a crafted callback could exfiltrate the token // to an attacker's server. Allow only HTTPS Altimate-owned hosts, an explicitly @@ -341,10 +430,7 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise { const redirect = `http://127.0.0.1:${boundPort}/callback` // Land on the sign-up page and let the user choose how to authenticate // (Google today, more providers later) rather than forcing Google. - const authorizeUrl = - `${webUrl}/register?client=altimate-code` + - `&redirect=${encodeURIComponent(redirect)}` + - `&state=${state}` + const authorizeUrl = await buildAuthorizeUrl(webUrl, redirect, state) // Try to open the browser. Failure is silent because the URL is // already surfaced elsewhere: the auth dialog in packages/tui/src/ @@ -363,7 +449,8 @@ export async function AltimateAuthPlugin(_input: PluginInput): Promise { // attempted". open() failures are swallowed above (the URL is also printed for the // user to paste), so this fires even when no browser actually launched. // The URL is never sent — it carries the CSRF `state`. - if (OnboardingTelemetry.isFunnelActive()) void OnboardingTelemetry.emit({ type: "gateway_device_code_issued" }) + if (OnboardingTelemetry.isFunnelActive()) + void OnboardingTelemetry.emit({ type: "gateway_device_code_issued" }) // One outcome per attempt. callback() closes over `result` and re-runs its whole body // on every invocation, so a repeated call would otherwise re-emit completion/failure diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index bb4c9ee467..ee3f87821e 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -3,6 +3,8 @@ import { Config } from "@/config/config" import { Flag } from "@/flag/flag" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Log } from "@/altimate/util/log" +// altimate_change — shared machine-id helper (race-safe, UUID-validated, size-capped) +import { getOrCreateMachineId } from "@/altimate/util/machine-id" import { createHash, randomUUID } from "crypto" import fs from "fs" import path from "path" @@ -1698,31 +1700,10 @@ export namespace Telemetry { } catch { // Account unavailable — proceed without user ID } - try { - const machineIdPath = path.join(os.homedir(), ".altimate", "machine-id") - try { - machineId = fs.readFileSync(machineIdPath, "utf8").trim() - } catch { - // altimate_change start — create exclusively so two threads cannot mint different ids. - // The TUI main thread and the server worker each initialise their own copy of this - // module, and on a genuinely new install both can find the file missing at the same - // moment. With a plain write, the loser's value overwrites the winner's while both keep - // their own in memory, so a single first run reports two machine_ids — breaking the - // fallback identity exactly on the run that matters most. `wx` makes one of them fail, - // and the loser re-reads what the winner wrote. - const candidate = randomUUID() - fs.mkdirSync(path.dirname(machineIdPath), { recursive: true }) - try { - fs.writeFileSync(machineIdPath, candidate, { encoding: "utf8", flag: "wx" }) - machineId = candidate - } catch { - machineId = fs.readFileSync(machineIdPath, "utf8").trim() - } - // altimate_change end - } - } catch { - // Machine ID unavailable — proceed without it - } + // altimate_change — use shared getOrCreateMachineId() from util/machine-id.ts. + // Returns "" on all error conditions (ENOENT: mints new UUID; EACCES/corrupt/oversized: + // logs + returns ""). No try/catch needed — all paths are handled inside. + machineId = getOrCreateMachineId() enabled = true log.info("telemetry initialized", { mode: "appinsights" }) // altimate_change — clear any existing interval before installing a new one. doInit() can diff --git a/packages/opencode/src/altimate/util/machine-id.ts b/packages/opencode/src/altimate/util/machine-id.ts new file mode 100644 index 0000000000..30956ceb03 --- /dev/null +++ b/packages/opencode/src/altimate/util/machine-id.ts @@ -0,0 +1,131 @@ +// altimate_change — shared machine-id helper extracted from plugin/altimate.ts. +// All three call sites (telemetry/index.ts, plugin/altimate.ts, cli/welcome.ts) +// use this to guarantee they converge on the same file and the same UUID value. +import { randomUUID } from "crypto" +import fs from "fs" +import os from "os" +import path from "path" +import { Log } from "./log" + +const log = Log.create({ service: "machine-id" }) + +// Max bytes to read from the machine-id file. A UUID is 36 chars; 512 bytes +// is generous enough for any valid value while capping pathological cases +// (multi-MB symlink targets, garbage-filled files). +const MAX_BYTES = 512 + +// RFC 4122 v4 UUID — the only format we mint, so the only format we accept. +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +// Read at most MAX_BYTES through a descriptor so the cap is ENFORCED at read +// time rather than being advisory: a plain readFileSync would slurp a file that +// grew or was swapped after an earlier lstat entirely into memory before UUID_RE +// could reject it. `fstat` on the open descriptor re-confirms a regular file of +// bounded size — the same file we then read — so both the main path and the +// EEXIST race re-read (which previously had no check at all) are covered. +// Returns "" for anything not a bounded regular file; callers treat that as invalid. +function readCappedUtf8(idPath: string): string { + const fd = fs.openSync(idPath, "r") + try { + const stat = fs.fstatSync(fd) + if (!stat.isFile() || stat.size > MAX_BYTES) return "" + const buf = Buffer.alloc(MAX_BYTES) + const bytesRead = fs.readSync(fd, buf, 0, MAX_BYTES, 0) + return buf.toString("utf8", 0, bytesRead).trim() + } finally { + fs.closeSync(fd) + } +} + +/** + * Read the machine-id from `~/.altimate/machine-id`, minting a new random UUID + * with `flag: "wx"` (exclusive create) if the file is absent. + * + * - **Race-safe**: two concurrent callers on a fresh install converge on the + * same UUID — the winner writes, the loser re-reads. + * - **Regular-file-only**: uses `lstat` and rejects symlinks / non-regular + * files rather than following them to an attacker-chosen target. + * - **Size-capped**: reads at most 512 bytes through a descriptor (enforced at + * read time, not an advisory pre-check) to avoid multi-MB file reads. + * - **UUID-validated**: rejects content that does not match RFC 4122 v4 UUID + * format (corrupt file, injected content, etc.) and returns `""` with a warn + * log so callers can omit the field rather than propagate garbage. + * + * @param machineIdPath Override path (for tests). Defaults to `~/.altimate/machine-id`. + * @returns A v4 UUID string, or `""` if the value is invalid or unreadable. + */ +export function getOrCreateMachineId(machineIdPath?: string): string { + const idPath = machineIdPath ?? path.join(os.homedir(), ".altimate", "machine-id") + + // --- Read path --- + let raw: string | undefined + try { + // lstat (not stat) so a symlink is inspected as itself rather than followed + // to an attacker-chosen target. Reject anything that is not a regular file + // (symlink, directory, socket, …) and cap read size to avoid multi-MB files. + const stat = fs.lstatSync(idPath) + if (!stat.isFile()) { + log.warn("machine-id is not a regular file — omitting", { path: idPath }) + return "" + } + if (stat.size > MAX_BYTES) { + log.warn("machine-id file exceeds size limit — omitting", { path: idPath, size: stat.size }) + return "" + } + raw = readCappedUtf8(idPath) + } catch (readErr) { + const code = (readErr as NodeJS.ErrnoException)?.code + if (code !== "ENOENT") { + // EACCES, EMFILE, etc. — log and bail; we cannot create either. + log.warn("machine-id read failed", { code, path: idPath }) + return "" + } + // File absent — fall through to create path below. + raw = undefined + } + + if (raw !== undefined) { + // Validate before returning: reject corrupt or symlink-injected content. + if (!UUID_RE.test(raw)) { + log.warn("machine-id file contains non-UUID content — omitting", { path: idPath }) + return "" + } + return raw + } + + // --- Create path (ENOENT) --- + // `flag: "wx"` is atomic exclusive-create: the OS guarantees only one writer + // succeeds. The loser re-reads what the winner wrote. + // + // mkdirSync MUST be inside this try: on a read-only $HOME, a restricted + // container, or a full disk it throws (EACCES/EROFS/ENOSPC), and the module's + // contract is to return "" on every error — never propagate. The auth path + // (buildCliContext) relies on this and no longer wraps the call itself. + const candidate = randomUUID() + try { + fs.mkdirSync(path.dirname(idPath), { recursive: true }) + fs.writeFileSync(idPath, candidate, { encoding: "utf8", flag: "wx" }) + return candidate + } catch (writeErr) { + const code = (writeErr as NodeJS.ErrnoException)?.code + if (code !== "EEXIST") { + log.warn("machine-id create failed", { code, path: idPath }) + return "" + } + // Lost the race — read what the winner wrote (bounded, same as the main read). + try { + const winner = readCappedUtf8(idPath) + if (!UUID_RE.test(winner)) { + log.warn("machine-id written by race winner is non-UUID — omitting", { path: idPath }) + return "" + } + return winner + } catch (rereadErr) { + log.warn("machine-id re-read after race failed", { + code: (rereadErr as NodeJS.ErrnoException)?.code, + path: idPath, + }) + return "" + } + } +} diff --git a/packages/opencode/src/cli/welcome.ts b/packages/opencode/src/cli/welcome.ts index 2b08a79b35..650a851a31 100644 --- a/packages/opencode/src/cli/welcome.ts +++ b/packages/opencode/src/cli/welcome.ts @@ -39,10 +39,14 @@ export function showWelcomeBannerIfNeeded(): void { // Remove marker first to avoid showing twice even if display fails fs.unlinkSync(markerPath) - // altimate_change start — use ~/.altimate/machine-id existence as a proxy for upgrade vs fresh install - // Since postinstall.mjs always writes the current version to the marker file, we can't reliably - // use installedVersion !== currentVersion for release builds. Instead, if machine-id exists, - // they've run the CLI before. + // altimate_change start — "upgrade" means the machine-id file already existed before this + // launch. Probe existence with existsSync only — do NOT mint here. Minting is left to + // Telemetry.doInit() (its job, not the welcome banner's); the first_launch machine_id is + // attached at flush time from telemetry module state, so it does not depend on minting here. + // NOTE: doInit's early call runs before an Instance is available, so its CONFIG opt-out gate + // fails open and it can still mint for a config-only opt-out user. That is a pre-existing + // telemetry-init gap (tracked separately), not something this banner can fix — this code just + // stops adding a SECOND minting site under an even weaker (env-only) gate. const machineIdPath = path.join(os.homedir(), ".altimate", "machine-id") const isUpgrade = fs.existsSync(machineIdPath) // altimate_change end diff --git a/packages/opencode/test/altimate/altimate-plugin.test.ts b/packages/opencode/test/altimate/altimate-plugin.test.ts new file mode 100644 index 0000000000..c6dbb791ce --- /dev/null +++ b/packages/opencode/test/altimate/altimate-plugin.test.ts @@ -0,0 +1,345 @@ +// altimate_change — tests for cli_context auth URL parameter +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" +import fs from "fs" +import path from "path" +import { buildAuthorizeUrl, buildCliContext } from "../../src/altimate/plugin/altimate" +import { getOrCreateMachineId } from "../../src/altimate/util/machine-id" +import { Config } from "../../src/config/config" +import { tmpdir } from "../fixture/fixture" + +const VALID_UUID = "550e8400-e29b-41d4-a716-446655440000" + +// buildCliContext resolves the telemetry opt-out via Config.get(), which throws +// "InstanceRef not provided" in the unit-test context and — by design — fails +// CLOSED, omitting machine_id. Stub Config.get() to an enabled config so the +// machine_id path is exercised; individual opt-out tests override the stub. +function stubConfig(impl: () => Promise) { + return spyOn(Config, "get").mockImplementation(impl as never) +} + +describe("buildCliContext", () => { + let cfg: ReturnType + let savedDisabled: string | undefined + + beforeEach(() => { + // Clear any real opt-out from the dev's shell so these "telemetry enabled" + // tests are not silently broken by an exported ALTIMATE_TELEMETRY_DISABLED. + savedDisabled = process.env.ALTIMATE_TELEMETRY_DISABLED + delete process.env.ALTIMATE_TELEMETRY_DISABLED + cfg = stubConfig(async () => ({})) + }) + afterEach(() => { + cfg.mockRestore() + if (savedDisabled === undefined) delete process.env.ALTIMATE_TELEMETRY_DISABLED + else process.env.ALTIMATE_TELEMETRY_DISABLED = savedDisabled + }) + + test("returns a valid base64url-encoded JSON blob with machine_id", async () => { + await using dir = await tmpdir() + const idPath = path.join(dir.path, "machine-id") + fs.writeFileSync(idPath, VALID_UUID, "utf8") + + const encoded = await buildCliContext(idPath) + + // base64url: only A-Z a-z 0-9 - _ (no +/=) + expect(encoded).toMatch(/^[A-Za-z0-9\-_]+$/) + + const ctx = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as Record + expect(ctx["v"]).toBe(1) + expect(ctx["machine_id"]).toBe(VALID_UUID) + expect(typeof ctx["cli_version"]).toBe("string") + }) + + test("creates machine_id file when absent and includes it in context", async () => { + await using dir = await tmpdir() + const nonExistentPath = path.join(dir.path, "subdir", "machine-id") + + const encoded = await buildCliContext(nonExistentPath) + const ctx = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as Record + + expect(ctx["v"]).toBe(1) + expect(typeof ctx["machine_id"]).toBe("string") + expect((ctx["machine_id"] as string).length).toBeGreaterThan(0) + // The same id must have been written to disk for telemetry to reuse + expect(fs.readFileSync(nonExistentPath, "utf8").trim()).toBe(ctx["machine_id"] as string) + }) + + test("trims whitespace from machine-id file", async () => { + await using dir = await tmpdir() + const idPath = path.join(dir.path, "machine-id") + fs.writeFileSync(idPath, ` ${VALID_UUID} \n`, "utf8") + + const encoded = await buildCliContext(idPath) + const ctx = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as Record + + expect(ctx["machine_id"]).toBe(VALID_UUID) + }) + + describe("telemetry opt-out", () => { + let savedEnv: string | undefined + + beforeEach(() => { + savedEnv = process.env.ALTIMATE_TELEMETRY_DISABLED + }) + afterEach(() => { + if (savedEnv === undefined) delete process.env.ALTIMATE_TELEMETRY_DISABLED + else process.env.ALTIMATE_TELEMETRY_DISABLED = savedEnv + }) + + test("omits machine_id when ALTIMATE_TELEMETRY_DISABLED=true", async () => { + process.env.ALTIMATE_TELEMETRY_DISABLED = "true" + await using dir = await tmpdir() + const idPath = path.join(dir.path, "machine-id") + fs.writeFileSync(idPath, VALID_UUID, "utf8") + + const encoded = await buildCliContext(idPath) + const ctx = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as Record + + expect(Object.prototype.hasOwnProperty.call(ctx, "machine_id")).toBe(false) + expect(ctx["v"]).toBe(1) + }) + + test("omits machine_id when config.telemetry.disabled is set", async () => { + delete process.env.ALTIMATE_TELEMETRY_DISABLED + cfg.mockImplementation((async () => ({ telemetry: { disabled: true } })) as never) + await using dir = await tmpdir() + const idPath = path.join(dir.path, "machine-id") + fs.writeFileSync(idPath, VALID_UUID, "utf8") + + const encoded = await buildCliContext(idPath) + const ctx = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as Record + + expect(Object.prototype.hasOwnProperty.call(ctx, "machine_id")).toBe(false) + }) + + test("omits machine_id when Config.get() throws (fails CLOSED in the worker)", async () => { + delete process.env.ALTIMATE_TELEMETRY_DISABLED + cfg.mockImplementation((async () => { + throw new Error("InstanceRef not provided") + }) as never) + await using dir = await tmpdir() + const idPath = path.join(dir.path, "machine-id") + fs.writeFileSync(idPath, VALID_UUID, "utf8") + + const encoded = await buildCliContext(idPath) + const ctx = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as Record + + // Config unreadable → must NOT transmit the durable id. + expect(Object.prototype.hasOwnProperty.call(ctx, "machine_id")).toBe(false) + }) + + test("includes machine_id when telemetry is enabled", async () => { + delete process.env.ALTIMATE_TELEMETRY_DISABLED + await using dir = await tmpdir() + const idPath = path.join(dir.path, "machine-id") + fs.writeFileSync(idPath, VALID_UUID, "utf8") + + const encoded = await buildCliContext(idPath) + const ctx = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as Record + + expect(ctx["machine_id"]).toBe(VALID_UUID) + }) + }) +}) + +describe("getOrCreateMachineId", () => { + test("returns existing id when file is present", async () => { + await using dir = await tmpdir() + const idPath = path.join(dir.path, "machine-id") + fs.writeFileSync(idPath, `${VALID_UUID}\n`, "utf8") + + expect(getOrCreateMachineId(idPath)).toBe(VALID_UUID) + }) + + test("creates a UUID file when absent and returns it", async () => { + await using dir = await tmpdir() + const idPath = path.join(dir.path, "subdir", "machine-id") + + const id = getOrCreateMachineId(idPath) + + expect(typeof id).toBe("string") + expect(id.length).toBeGreaterThan(0) + expect(fs.readFileSync(idPath, "utf8").trim()).toBe(id) + }) + + test("wx EEXIST (lost race) re-reads and returns the winner's id", () => { + const winner = "550e8400-e29b-41d4-a716-4466554400ff" + // Simulate a real race: the file is absent at lstat (ENOENT → create path), + // but a concurrent writer created it before our `wx` write, so writeFileSync + // throws EEXIST and we must re-read what the winner wrote. + const lstatSpy = spyOn(fs, "lstatSync").mockImplementation(() => { + const e = new Error("nope") as NodeJS.ErrnoException + e.code = "ENOENT" + throw e + }) + const mkdirSpy = spyOn(fs, "mkdirSync").mockImplementation(() => undefined as never) + const writeSpy = spyOn(fs, "writeFileSync").mockImplementation(() => { + const e = new Error("exists") as NodeJS.ErrnoException + e.code = "EEXIST" + throw e + }) + // The re-read goes through the bounded, descriptor-based readCappedUtf8: + // open → fstat (regular file, bounded) → read ≤ MAX_BYTES → close. + const openSpy = spyOn(fs, "openSync").mockImplementation(() => 3 as never) + const fstatSpy = spyOn(fs, "fstatSync").mockImplementation( + () => ({ isFile: () => true, size: winner.length }) as never, + ) + const readSpy = spyOn(fs, "readSync").mockImplementation(((_fd: number, buf: Buffer) => + Buffer.from(winner).copy(buf)) as never) + const closeSpy = spyOn(fs, "closeSync").mockImplementation(() => undefined as never) + try { + expect(getOrCreateMachineId("/does/not/matter/machine-id")).toBe(winner) + expect(readSpy).toHaveBeenCalled() + } finally { + lstatSpy.mockRestore() + mkdirSpy.mockRestore() + writeSpy.mockRestore() + openSpy.mockRestore() + fstatSpy.mockRestore() + readSpy.mockRestore() + closeSpy.mockRestore() + } + }) + + test("returns '' when mkdir fails (read-only home) instead of throwing", () => { + // Regression guard: mkdirSync must be inside the try/catch so a read-only + // $HOME / restricted container returns "" rather than propagating and + // breaking sign-in via buildCliContext → buildAuthorizeUrl → authorize(). + const lstatSpy = spyOn(fs, "lstatSync").mockImplementation(() => { + const e = new Error("nope") as NodeJS.ErrnoException + e.code = "ENOENT" + throw e + }) + const mkdirSpy = spyOn(fs, "mkdirSync").mockImplementation(() => { + const e = new Error("eacces") as NodeJS.ErrnoException + e.code = "EACCES" + throw e + }) + try { + expect(getOrCreateMachineId("/root/.altimate/machine-id")).toBe("") + } finally { + lstatSpy.mockRestore() + mkdirSpy.mockRestore() + } + }) +}) + +describe("buildAuthorizeUrl", () => { + let cfg: ReturnType + let savedDisabled: string | undefined + + beforeEach(() => { + savedDisabled = process.env.ALTIMATE_TELEMETRY_DISABLED + delete process.env.ALTIMATE_TELEMETRY_DISABLED + cfg = stubConfig(async () => ({})) + }) + afterEach(() => { + cfg.mockRestore() + if (savedDisabled === undefined) delete process.env.ALTIMATE_TELEMETRY_DISABLED + else process.env.ALTIMATE_TELEMETRY_DISABLED = savedDisabled + }) + + test("carries cli_context in the fragment (not the query), decoding to the machine_id", async () => { + await using dir = await tmpdir() + const idPath = path.join(dir.path, "machine-id") + fs.writeFileSync(idPath, VALID_UUID, "utf8") + + // Forward the temp machine-id path so the test never writes into the real $HOME. + const url = await buildAuthorizeUrl( + "https://app.myaltimate.com", + "http://127.0.0.1:7317/callback", + "test-state-abc", + idPath, + ) + + expect(url).toContain("#cli_context=") + expect(url).toContain("client=altimate-code") + expect(url).toContain("state=test-state-abc") + expect(url).toContain("redirect=") + + const parsed = new URL(url) + // The durable id must NOT be in the query string (it would hit access logs). + expect(parsed.searchParams.has("cli_context")).toBe(false) + + const encoded = new URLSearchParams(parsed.hash.replace(/^#/, "")).get("cli_context") + expect(encoded).toBeTruthy() + + const ctx = JSON.parse(Buffer.from(encoded!, "base64url").toString("utf8")) as Record + expect(ctx["v"]).toBe(1) + expect(typeof ctx["cli_version"]).toBe("string") + // Non-vacuous: the machine_id we wrote must round-trip through the URL. + expect(ctx["machine_id"]).toBe(VALID_UUID) + }) + + test("removing the #cli_context= line would drop the param — integration is guarded", async () => { + await using dir = await tmpdir() + const idPath = path.join(dir.path, "machine-id") + fs.writeFileSync(idPath, VALID_UUID, "utf8") + + const url = await buildAuthorizeUrl( + "https://app.myaltimate.com", + "http://127.0.0.1:7317/callback", + "state-xyz", + idPath, + ) + const parsed = new URL(url) + expect(new URLSearchParams(parsed.hash.replace(/^#/, "")).has("cli_context")).toBe(true) + // And never in the query string, where it would be logged. + expect(parsed.searchParams.has("cli_context")).toBe(false) + }) +}) + +// altimate_change — failure-mode coverage for getOrCreateMachineId +describe("getOrCreateMachineId — failure modes", () => { + test("returns empty string for non-UUID content without throwing", async () => { + await using dir = await tmpdir() + const idPath = path.join(dir.path, "machine-id") + fs.writeFileSync(idPath, "not-a-uuid-at-all", "utf8") + + expect(getOrCreateMachineId(idPath)).toBe("") + }) + + test("returns empty string for oversized file without throwing", async () => { + await using dir = await tmpdir() + const idPath = path.join(dir.path, "machine-id") + fs.writeFileSync(idPath, "x".repeat(513), "utf8") + + expect(getOrCreateMachineId(idPath)).toBe("") + }) + + test("returns empty string for a valid UUID of the wrong version", async () => { + await using dir = await tmpdir() + const idPath = path.join(dir.path, "machine-id") + // v1 UUID (third group starts with 1, not 4) + fs.writeFileSync(idPath, "550e8400-e29b-11d4-a716-446655440000", "utf8") + + expect(getOrCreateMachineId(idPath)).toBe("") + }) + + test("returns empty string for an empty file without minting over it", async () => { + await using dir = await tmpdir() + const idPath = path.join(dir.path, "machine-id") + fs.writeFileSync(idPath, "", "utf8") + + expect(getOrCreateMachineId(idPath)).toBe("") + expect(fs.readFileSync(idPath, "utf8")).toBe("") + }) + + test("returns empty string when the path is a directory", async () => { + await using dir = await tmpdir() + const idPath = path.join(dir.path, "machine-id") + fs.mkdirSync(idPath) + + expect(getOrCreateMachineId(idPath)).toBe("") + }) + + test("returns empty string when the path is a symlink (not followed)", async () => { + await using dir = await tmpdir() + const targetPath = path.join(dir.path, "target") + fs.writeFileSync(targetPath, "550e8400-e29b-41d4-a716-446655440099", "utf8") + const linkPath = path.join(dir.path, "machine-id") + fs.symlinkSync(targetPath, linkPath) + + expect(getOrCreateMachineId(linkPath)).toBe("") + }) +})