diff --git a/docs/docs/reference/security-faq.md b/docs/docs/reference/security-faq.md index 71fb872318..b81ced97ff 100644 --- a/docs/docs/reference/security-faq.md +++ b/docs/docs/reference/security-faq.md @@ -132,6 +132,7 @@ export ALTIMATE_TELEMETRY_DISABLED=true - **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`. - **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 `ALTIMATE_TELEMETRY_DISABLED=true` is set — 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..110d739fb5 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`), an anonymized session identifier (the `machine-id` UUID) 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 never used for tracking, advertising, or cross-site identification. Respecting `ALTIMATE_TELEMETRY_DISABLED=true` suppresses this: when telemetry opt-out is set, the machine ID is omitted from the authorization URL entirely. + ### 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..0cd41262cf 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,71 @@ const DEFAULT_WEB_URL = "https://app.myaltimate.com" // deliver. const DEFAULT_API_URL = "https://api.myaltimate.com" +const log = Log.create({ service: "altimate-plugin" }) + +// altimate_change — getOrCreateMachineId is now in util/machine-id.ts (re-exported +// from there so existing test imports that reference this module continue to work). +export { getOrCreateMachineId } from "../util/machine-id" + +// 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 calls posthog.alias(email, machine_id) to associate +// the device with the authenticated account in product 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. +// The frontend reads it from the fragment (see useCliContext.ts). The fragment +// must be the last URL segment, after all query params. +export async function buildCliContext(machineIdPath?: string): Promise { + // altimate_change start — honour both telemetry opt-out gates, mirroring + // telemetry/index.ts::doInit exactly: + // 1. ALTIMATE_TELEMETRY_DISABLED=true env var (early, always-works escape hatch) + // 2. config.telemetry.disabled (resolved via the async Config.get()) + // Config.get() may throw outside an Instance context; treat a config failure as + // "not disabled" (same as doInit) — the env var above is the hard opt-out. + let disabled = process.env.ALTIMATE_TELEMETRY_DISABLED === "true" + if (!disabled) { + try { + const userConfig = (await Config.get()) as any + disabled = Boolean(userConfig.telemetry?.disabled) + } catch { + // Config unavailable — proceed with telemetry enabled. + } + } + 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 key when empty (matches telemetry + // module pattern: `...(machineId && { machine_id: machineId })`). Sending "" + // is meaningless for posthog.alias() and misleads downstream consumers. + 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. +export async function buildAuthorizeUrl(webUrl: string, redirect: string, state: string): Promise { + return ( + `${webUrl}/register?client=altimate-code` + + `&redirect=${encodeURIComponent(redirect)}` + + `&state=${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())}` + ) +} +// 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 +411,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 +430,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..8694886fd5 --- /dev/null +++ b/packages/opencode/src/altimate/util/machine-id.ts @@ -0,0 +1,105 @@ +// 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 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 to avoid multi-MB file attacks. + * - **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 = fs.readFileSync(idPath, "utf8").trim() + } 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. + const candidate = randomUUID() + fs.mkdirSync(path.dirname(idPath), { recursive: true }) + try { + 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. + try { + const winner = fs.readFileSync(idPath, "utf8").trim() + 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..9cc1763750 100644 --- a/packages/opencode/src/cli/welcome.ts +++ b/packages/opencode/src/cli/welcome.ts @@ -6,6 +6,8 @@ import { EOL } from "os" // altimate_change start — import Telemetry for first_launch event import { Telemetry } from "../altimate/telemetry" // altimate_change end +// altimate_change — import shared machine-id utility so the path is canonical across all call sites +import { getOrCreateMachineId } from "../altimate/util/machine-id" const APP_NAME = "altimate-code" const MARKER_FILE = ".installed-version" @@ -39,12 +41,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 FIRST — do NOT use getOrCreateMachineId() as the + // probe, because it mints the file on a fresh install and would then report every new user + // as an upgrade. After probing, mint the id (unless telemetry is opted out via env) so + // telemetry.doInit() finds it ready — welcome.ts runs before doInit(). const machineIdPath = path.join(os.homedir(), ".altimate", "machine-id") const isUpgrade = fs.existsSync(machineIdPath) + if (process.env.ALTIMATE_TELEMETRY_DISABLED !== "true") getOrCreateMachineId() // altimate_change end // altimate_change start — track first launch for new user counting (privacy-safe: only version + machine_id) 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..91c0916b4c --- /dev/null +++ b/packages/opencode/test/altimate/altimate-plugin.test.ts @@ -0,0 +1,300 @@ +// altimate_change — tests for cli_context auth URL parameter +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import * as fs from "fs" +import * as os from "os" +import * as path from "path" +import { buildAuthorizeUrl, buildCliContext, getOrCreateMachineId } from "../../src/altimate/plugin/altimate" + +describe("buildCliContext", () => { + test("returns a valid base64url-encoded JSON blob with machine_id", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "altimate-ctx-")) + const idPath = path.join(tmpDir, "machine-id") + fs.writeFileSync(idPath, "550e8400-e29b-41d4-a716-446655440000", "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("550e8400-e29b-41d4-a716-446655440000") + expect(typeof ctx["cli_version"]).toBe("string") + + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + test("creates machine_id file when absent and includes it in context", async () => { + // getOrCreateMachineId mints a UUID when the file is missing, so machine_id + // is always present (as a non-empty string) unless telemetry is disabled. + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "altimate-ctx-new-")) + const nonExistentPath = path.join(tmpDir, "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) + // machine_id must be present and non-empty (newly minted UUID) + 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 use + expect(fs.readFileSync(nonExistentPath, "utf8").trim()).toBe(ctx["machine_id"] as string) + + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + test("trims whitespace from machine-id file", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "altimate-ctx-ws-")) + const idPath = path.join(tmpDir, "machine-id") + // Many editors/tools write a trailing newline + fs.writeFileSync(idPath, " 550e8400-e29b-41d4-a716-446655440001 \n", "utf8") + + const encoded = await buildCliContext(idPath) + const ctx = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as Record + + expect(ctx["machine_id"]).toBe("550e8400-e29b-41d4-a716-446655440001") + + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + 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 key when ALTIMATE_TELEMETRY_DISABLED=true", async () => { + process.env.ALTIMATE_TELEMETRY_DISABLED = "true" + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "altimate-ctx-disabled-")) + const idPath = path.join(tmpDir, "machine-id") + fs.writeFileSync(idPath, "550e8400-e29b-41d4-a716-446655440004", "utf8") + + const encoded = await buildCliContext(idPath) + const ctx = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as Record + + // machine_id must be absent when telemetry is disabled + expect(Object.prototype.hasOwnProperty.call(ctx, "machine_id")).toBe(false) + expect(ctx["v"]).toBe(1) + + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + test("includes machine_id when ALTIMATE_TELEMETRY_DISABLED is not set", async () => { + delete process.env.ALTIMATE_TELEMETRY_DISABLED + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "altimate-ctx-enabled-")) + const idPath = path.join(tmpDir, "machine-id") + fs.writeFileSync(idPath, "550e8400-e29b-41d4-a716-446655440002", "utf8") + + const encoded = await buildCliContext(idPath) + const ctx = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as Record + + expect(ctx["machine_id"]).toBe("550e8400-e29b-41d4-a716-446655440002") + + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + }) +}) + +describe("getOrCreateMachineId", () => { + test("returns existing id when file is present", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "altimate-mid-")) + const idPath = path.join(tmpDir, "machine-id") + fs.writeFileSync(idPath, "550e8400-e29b-41d4-a716-446655440003\n", "utf8") + + expect(getOrCreateMachineId(idPath)).toBe("550e8400-e29b-41d4-a716-446655440003") + + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + test("creates a UUID file when absent and returns it", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "altimate-mid-create-")) + const idPath = path.join(tmpDir, "subdir", "machine-id") + + const id = getOrCreateMachineId(idPath) + + // Must be a non-empty string that was written to disk + expect(typeof id).toBe("string") + expect(id.length).toBeGreaterThan(0) + expect(fs.readFileSync(idPath, "utf8").trim()).toBe(id) + + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + test("two concurrent callers with absent file converge on the same id", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "altimate-mid-race-")) + const idPath = path.join(tmpDir, "machine-id") + + // Simulate a race: call getOrCreateMachineId twice before either has written + const [id1, id2] = await Promise.all([ + Promise.resolve(getOrCreateMachineId(idPath)), + Promise.resolve(getOrCreateMachineId(idPath)), + ]) + + expect(id1).toBe(id2) + + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) +}) + +describe("buildAuthorizeUrl", () => { + test("URL contains cli_context param that decodes to valid JSON", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "altimate-auth-url-")) + const idPath = path.join(tmpDir, "machine-id") + fs.writeFileSync(idPath, "550e8400-e29b-41d4-a716-446655440005", "utf8") + + // Temporarily override machine-id path via a patched buildCliContext call + // by providing a custom machine_id file — buildAuthorizeUrl uses buildCliContext() + // internally with no path arg, so test the URL shape via the exported helper. + const url = await buildAuthorizeUrl( + "https://app.myaltimate.com", + "http://127.0.0.1:7317/callback", + "test-state-abc", + ) + + // cli_context rides in the fragment (#), not the query string + expect(url).toContain("#cli_context=") + expect(url).toContain("client=altimate-code") + expect(url).toContain("state=test-state-abc") + expect(url).toContain("redirect=") + + // The durable id must NOT be in the query string (it would hit access logs) + const parsed = new URL(url) + expect(parsed.searchParams.has("cli_context")).toBe(false) + + // Extract and decode cli_context from the fragment + 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") + // machine_id may or may not be present depending on env, but if present must be a string + if (Object.prototype.hasOwnProperty.call(ctx, "machine_id")) { + expect(typeof ctx["machine_id"]).toBe("string") + expect((ctx["machine_id"] as string).length).toBeGreaterThan(0) + } + + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + test("deleting cli_context line would cause cli_context to be absent — URL integration is guarded", async () => { + // This test asserts that buildAuthorizeUrl really does embed cli_context in + // the fragment. If the #cli_context=... line were removed, this test fails. + const url = await buildAuthorizeUrl("https://app.myaltimate.com", "http://127.0.0.1:7317/callback", "state-xyz") + 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 — additional failure mode tests for getOrCreateMachineId (MINOR 8) +describe("getOrCreateMachineId — failure modes", () => { + test("returns empty string for non-UUID content without throwing", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "altimate-mid-invalid-")) + const idPath = path.join(tmpDir, "machine-id") + // Write garbage that is not a v4 UUID + fs.writeFileSync(idPath, "not-a-uuid-at-all", "utf8") + + const id = getOrCreateMachineId(idPath) + + // Must return empty string (warn logged internally, not thrown) + expect(id).toBe("") + + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + test("returns empty string for oversized file without throwing", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "altimate-mid-big-")) + const idPath = path.join(tmpDir, "machine-id") + // Write a file larger than the 512-byte cap + fs.writeFileSync(idPath, "x".repeat(513), "utf8") + + const id = getOrCreateMachineId(idPath) + + // Must return empty string — oversized content rejected + expect(id).toBe("") + + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + test("returns empty string when file has valid UUID format but wrong version", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "altimate-mid-v1-")) + const idPath = path.join(tmpDir, "machine-id") + // v1 UUID (time-based, not version 4) — third group starts with 1, not 4 + fs.writeFileSync(idPath, "550e8400-e29b-11d4-a716-446655440000", "utf8") + + const id = getOrCreateMachineId(idPath) + + // Strict UUID v4 validation rejects this + expect(id).toBe("") + + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + test("returns empty string for an empty file without minting over it", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "altimate-mid-empty-")) + const idPath = path.join(tmpDir, "machine-id") + // 0-byte file exists — must NOT be treated as absent (no exclusive-create mint) + fs.writeFileSync(idPath, "", "utf8") + + const id = getOrCreateMachineId(idPath) + + // Empty content fails UUID validation → "" (and the file is left untouched) + expect(id).toBe("") + expect(fs.readFileSync(idPath, "utf8")).toBe("") + + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + test("returns empty string when the path is a directory", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "altimate-mid-dir-")) + const idPath = path.join(tmpDir, "machine-id") + // A directory at the machine-id path — lstat rejects it as a non-regular file + fs.mkdirSync(idPath) + + const id = getOrCreateMachineId(idPath) + + expect(id).toBe("") + + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + test("returns empty string when the path is a symlink (not followed)", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "altimate-mid-symlink-")) + // Target holds a perfectly valid UUID — lstat must still reject the symlink + // itself rather than following it to read the target. + const targetPath = path.join(tmpDir, "target") + fs.writeFileSync(targetPath, "550e8400-e29b-41d4-a716-446655440099", "utf8") + const linkPath = path.join(tmpDir, "machine-id") + fs.symlinkSync(targetPath, linkPath) + + const id = getOrCreateMachineId(linkPath) + + expect(id).toBe("") + + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + test("buildCliContext omits machine_id when file contains non-UUID content", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "altimate-ctx-corrupt-")) + const idPath = path.join(tmpDir, "machine-id") + fs.writeFileSync(idPath, "not-a-uuid", "utf8") + + const encoded = await buildCliContext(idPath) + const ctx = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as Record + + // machine_id must be absent — non-UUID content is rejected + expect(Object.prototype.hasOwnProperty.call(ctx, "machine_id")).toBe(false) + + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) +})