Skip to content

feat: append cli_context param to altimate auth URL for PostHog session correlation - #1068

Open
altimate-harness-bot[bot] wants to merge 1 commit into
mainfrom
feat/cli-context-auth
Open

feat: append cli_context param to altimate auth URL for PostHog session correlation#1068
altimate-harness-bot[bot] wants to merge 1 commit into
mainfrom
feat/cli-context-auth

Conversation

@altimate-harness-bot

@altimate-harness-bot altimate-harness-bot Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Appends a cli_context query param to the browser auth URL opened by the CLI during sign-in.

  • buildCliContext() encodes { v: 1, machine_id, cli_version } as a base64url JSON blob
  • machine_id is read from ~/.altimate/machine-id (random UUID, non-PII) — the same value already sent to Azure App Insights telemetry
  • On the frontend, cli_context is decoded and passed to posthog.register() + posthog.alias() to link the CLI device to the authenticated user

Companion PR: AltimateAI/altimate-frontend#3106

Requested by @saravmajestic via harness


Summary by cubic

Adds a base64url-encoded cli_context to the CLI auth URL so the web app can link the browser session to the CLI device in PostHog. Improves analytics without sending PII.

  • New Features
    • Adds buildCliContext() encoding { v: 1, machine_id, cli_version } (version from @opencode-ai/core/installation/version).
    • Reads machine_id from ~/.altimate/machine-id; trims whitespace; if missing, sets machine_id to "" and logs a debug message.
    • Appends &cli_context=... to the /register URL; adds tests for valid encoding, missing file behavior, and whitespace trimming.

Written for commit b8c72c5. Summary will update on new commits.

Review in cubic

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

This PR doesn't fully meet our contributing guidelines and PR template.

What needs to be fixed:

  • PR description is missing required template sections. Please use the PR template.

Please edit this PR description to address the above within 2 hours, or it will be automatically closed.

If you believe this was flagged incorrectly, please let a maintainer know.

…rrelation

- Append base64url-encoded cli_context param to the register URL opened
  by AltimateAuthPlugin. Context blob: { v, machine_id, cli_version }.
- machine_id is the existing stable UUID from ~/.altimate/machine-id
  (already in every App Insights event). If the file is missing, log a
  debug message instead of silently omitting.
- Export buildCliContext() and add 3 unit tests covering: valid context,
  missing machine-id file, and whitespace trimming.
@altimate-harness-bot
altimate-harness-bot Bot force-pushed the feat/cli-context-auth branch from 33de593 to b8c72c5 Compare August 4, 2026 01:49
@saravmajestic saravmajestic self-assigned this Aug 4, 2026
@saravmajestic
saravmajestic requested a review from sahrizvi August 4, 2026 01:51
@saravmajestic
saravmajestic marked this pull request as ready for review August 4, 2026 01:52

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

@sahrizvi sahrizvi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

Verdict: request changes — 0 critical, 4 major, 6 minor, 3 nits.

The mechanics here are right: base64url is the correct codec (unpadded, no +//, so no escaping surprises), the payload is version-tagged with v: 1 before anyone needs it, and a failed read never blocks sign-in. Exporting buildCliContext with an injectable path also makes it testable against the real filesystem instead of a mocked fs.

Two things block merge. First, the change transmits a persistent device identifier in a way the product's own published privacy documentation says it will not. Second, the feature can silently fail to do the one thing it exists to do, because it reads a file that only the telemetry module knows how to create.

Detailed findings are inline. Below is what has no single line to attach to.

Documentation changes needed (files not in this diff)

  • docs/docs/reference/telemetry.md:150 and docs/docs/reference/security-faq.md:133 state, without qualification, that "Both identifiers are only sent when telemetry is enabled." This PR sends the machine id on the auth URL regardless of the telemetry setting. Either gate the parameter on the opt-out or correct the promise — the current combination is a written commitment the code does not keep.
  • docs/docs/reference/telemetry.md:147 and docs/docs/reference/security-faq.md:132 describe the machine id as serving "only to distinguish one machine from another in aggregate analytics" and "NOT tied to ... identity". Linking the CLI device to an authenticated user is worth disclosing there.

Cross-repo contract to confirm on the companion frontend PR

  • Decode as base64url, not standard base64.
  • Require v === 1; reject unknown versions rather than best-effort parsing.
  • Cap decoded length and catch base64/JSON parse failures — this param is attacker-suppliable.
  • Treat cli_version: "local" as a legitimate development value, not a release version.
  • Decide explicitly what an empty or absent machine_id means. If it means "do not alias", enforce that — aliasing on an empty value would merge unrelated anonymous sessions into a single identity.

Nits (non-blocking)

  • Sync I/O on the interactive auth pathreadFileSync inside an async authorize(). The file is tiny and telemetry/index.ts already reads it synchronously, so this matches existing habit; worth changing only if the shared machine-id helper ends up async.
  • Manual URL concatenation — the browser-OAuth plugins in this repo build authorize URLs with URLSearchParams (src/plugin/xai.ts, codex.ts, digitalocean.ts, snowflake-cortex.ts). The manual style predates this PR, which adds a second hand-escaped param to it. Follow-up cleanup, not a blocker.
  • Weak version assertionexpect(typeof ctx["cli_version"]).toBe("string") does catch the key being dropped or renamed, so it is not vacuous, but it never checks the value. expect(ctx["cli_version"]).toBe(InstallationVersion) is strictly stronger. (InstallationVersion is typeof-guarded at packages/core/src/installation/version.ts:7 and can never be a non-string.)

Considered and not raised

  • The optional machineIdPath? parameter is a legitimate testability seam, not a smell — a test-only env var would be worse, since it makes test behaviour reachable in production builds.
  • The oversized-file concern is not a denial-of-service vector; the file sits in the user's own home directory. The real consequence is a broken auth URL, which is covered inline.
  • The first-run window where telemetry has not yet written the machine id is real but narrow: src/index.ts:126 starts Telemetry.init() at CLI startup, long before a human clicks through sign-in. It is fire-and-forget and the write sits behind two awaits, so the race exists — but the deterministic failure is the opt-out path, not this.

Verified locally: bun test test/altimate/altimate-plugin.test.ts -> 3 pass, 7 expect calls.

Comment on lines +60 to +70
export function buildCliContext(machineIdPath?: string): string {
const idPath = machineIdPath ?? path.join(os.homedir(), ".altimate", "machine-id")
let machineId = ""
try {
machineId = fs.readFileSync(idPath, "utf8").trim()
} catch {
log.debug("machine-id file not found — cli_context will omit machine_id")
}
const ctx = { v: 1, machine_id: machineId, cli_version: InstallationVersion }
return Buffer.from(JSON.stringify(ctx)).toString("base64url")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR — Logic Error / Design: machine-id lifecycle is split; this reads, only telemetry creates

buildCliContext() reads ~/.altimate/machine-id but never creates it. The only writer is Telemetry.doInit() (telemetry/index.ts:1702-1722), which carries race-safe exclusive-create (flag: "wx") logic this code does not share.

When the file is absent, this yields machine_id: "" while telemetry goes on to mint and use a real UUID — so the browser session and the CLI device carry different identities, and the correlation this PR exists to provide silently fails.

The path path.join(os.homedir(), ".altimate", "machine-id") is now constructed independently in three places:

  • telemetry/index.ts:1702 — read + create
  • plugin/altimate.ts:61 — read only (new here)
  • cli/welcome.ts:46 — existence check

Fix: extract one shared helper with the existing read-or-create wx semantics and use it at all three call sites.

Note this alone does not resolve the opt-out issue or the empty-value issue flagged separately — the helper also needs to carry the consent decision and return an optional, validated id.

`&redirect=${encodeURIComponent(redirect)}` +
`&state=${state}`
`&state=${state}` +
`&cli_context=${encodeURIComponent(buildCliContext())}`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR — Security (privacy): the machine ID is sent even when telemetry is disabled, contradicting the published docs

Telemetry.doInit() returns early — before the machine-id block — when ALTIMATE_TELEMETRY_DISABLED=true or config.telemetry.disabled is set (telemetry/index.ts:1667-1679). buildCliContext() checks neither, so this parameter is appended unconditionally.

A user who opted out but has a machine-id file from an earlier run still transmits that stable device identifier on every sign-in, specifically for product analytics.

The shipped documentation promises the opposite without qualification:

  • docs/docs/reference/telemetry.md:150 — "Both identifiers are only sent when telemetry is enabled."
  • docs/docs/reference/security-faq.md:133 — "Both identifiers are only sent when telemetry is enabled."

Fix: resolve the opt-out through the same config/env path telemetry uses, and omit machine_id entirely when the user has opted out.

(The mirror case — opted out with no pre-existing file, so machine_id is permanently "" — is reasonable behaviour, but it should be a deliberate documented decision rather than a side effect.)

Comment on lines +369 to +370
`&state=${state}` +
`&cli_context=${encodeURIComponent(buildCliContext())}`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR — Security: a persistent device identifier lives in a URL query parameter

Query params land in browser history, app.myaltimate.com access logs, any CDN/WAF in front of it, the clipboard when a user copies this URL for an SSH/tmux sign-in, and potentially a Referer header if /register loads third-party resources.

That is a durable copy of a device identifier scattered across systems that have no retention policy for it — unlike the telemetry pipeline, which does.

Fix (any of):

  • Confirm the param is scrubbed from access logs and that /register sets a restrictive Referrer-Policy.
  • Move the payload to a URL fragment (#cli_context=...) — never transmitted to the server, still readable by the page, which fits this use case exactly.
  • Stronger long-term: send a short-lived correlation token that maps to the device server-side, rather than the durable identifier itself.

const idPath = machineIdPath ?? path.join(os.homedir(), ".altimate", "machine-id")
let machineId = ""
try {
machineId = fs.readFileSync(idPath, "utf8").trim()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR — Bug / Security: no size or format validation on contents copied into the URL

readFileSync(idPath, "utf8").trim() accepts whatever is on disk — a multi-megabyte file, a symlink to another file, binary data (silently producing U+FFFD), embedded newlines — and all of it is base64-encoded into the authorize URL.

  • A corrupt or oversized file produces a URL past browser/proxy length limits (~2 KB on older stacks, 8 KB on many servers), turning a working sign-in into an opaque browser error. The read is fail-open for missing files but not for malformed ones.
  • A symlink planted at ~/.altimate/machine-id copies another file's contents into a URL sent to and logged by Altimate's servers. Not a privilege-boundary break — anyone who can write that path already controls the account — but a real exfiltration primitive that validation removes for free.

Fix: use lstat (not stat, which follows symlinks and would still accept a symlink to a regular file), reject anything oversized or not a regular file, then validate shape:

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
machineId = UUID_RE.test(raw) ? raw : ""

The canonical writer at telemetry/index.ts:1713 uses randomUUID(), so a UUID check rejects nothing legitimate.

Comment on lines +57 to +59
// Build a base64url-encoded context blob so the frontend can correlate this
// browser auth session with CLI telemetry. Fields are minimal and non-PII:
// machine_id is a random UUID stored locally, never an email or real identity.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR — Documentation: the comment does not disclose the CLI-to-account linkage

The raw value is indeed still a random UUID, so "never an email or real identity" is literally true. But the stated purpose of this change is to link that device to an authenticated user in analytics, and the user-facing docs describe the identifier as "purely random and serves only to distinguish one machine from another in aggregate analytics" (docs/docs/reference/telemetry.md:147) and "NOT tied to your hardware, OS, or identity" (docs/docs/reference/security-faq.md:132).

Fix: soften this comment and add the disclosure to both docs — that signing in associates the anonymous machine id with the account in analytics. This is an additive disclosure gap, distinct from the flat contradiction flagged on the URL line.

} catch {
log.debug("machine-id file not found — cli_context will omit machine_id")
}
const ctx = { v: 1, machine_id: machineId, cli_version: InstallationVersion }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR — Design: an empty machine_id is transmitted rather than omitted

Every failure path converges on machine_id: "", and the empty value is still sent. The telemetry module already handles this correctly — ...(machineId && { machine_id: machineId }) at telemetry/index.ts:1570 omits the key — so this diverges from the pattern it imitates.

The test comment at altimate-plugin.test.ts:34 claims the empty string lets the frontend distinguish "error reading" from "no key", but the key is never omitted in any path, so that distinction does not exist.

If the companion frontend aliases on the empty value, unrelated anonymous sessions merge into one identity. That consequence lives in the other repository and is unverified here — worth confirming on that side.

Fix:

const ctx: Record<string, unknown> = { v: 1, cli_version: InstallationVersion }
if (machineId) ctx["machine_id"] = machineId

and make "absent means do not alias" explicit in the frontend contract.

Comment on lines +65 to +67
} catch {
log.debug("machine-id file not found — cli_context will omit machine_id")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR — Code Quality: the catch and its log describe a narrower failure than they handle

The bare catch swallows EACCES, EISDIR, ELOOP, ENOTDIR and I/O errors, but logs "machine-id file not found". It also says it "will omit machine_id" when it in fact sends "". Someone debugging a missing correlation caused by a permissions problem is actively misled.

Fix:

} catch (err) {
  const code = (err as NodeJS.ErrnoException)?.code
  if (code === "ENOENT") log.debug("machine-id not present for cli_context")
  else log.warn("machine-id read failed", { code, path: idPath })
}

Non-ENOENT codes indicate a real local problem and deserve more than debug.

log.debug("machine-id file not found — cli_context will omit machine_id")
}
const ctx = { v: 1, machine_id: machineId, cli_version: InstallationVersion }
return Buffer.from(JSON.stringify(ctx)).toString("base64url")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR — Documentation: the frontend contract is unspecified, and this payload is untrusted input on that side

Nothing records what the consumer must do: decode base64url (not standard base64), require v === 1, cap decoded size, catch base64/JSON parse failures, validate field types, and treat cli_version: "local" as a legitimate dev value rather than a release.

From the frontend's perspective a URL query param is attacker-suppliable — anyone can hand-craft one and load /register.

Fix: add JSDoc here stating the contract, and make sure the companion PR validates rather than trusts.

import * as path from "path"
import { buildCliContext } from "../../src/altimate/plugin/altimate"

describe("buildCliContext", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR — Testing: the integration point is untested

All three tests exercise buildCliContext() in isolation. Nothing asserts that the authorize URL actually carries cli_context, that it decodes back, or that client / redirect / state survive alongside it.

Delete the &cli_context=... line in altimate.ts and this entire suite still passes — which is the definition of an untested feature.

Fix: extract a buildAuthorizeUrl() and assert on it via new URL() / URLSearchParams, then decode cli_context independently.


describe("buildCliContext", () => {
test("returns a valid base64url-encoded JSON blob with machine_id", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "altimate-ctx-"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR — Testing: no failure-mode coverage, and the repo's temp-dir fixture is bypassed

Untested paths, several of which carry the bugs flagged elsewhere in this review: telemetry opted out, permission-denied on an existing file, empty file (0 bytes), non-UUID or binary contents, oversized file, path-is-a-directory, and symlink.

The fixture value "test-uuid-1234" is also not a UUID, which quietly blesses arbitrary contents as acceptable input.

Separately, this hand-rolls fs.mkdtempSync + manual fs.rmSync cleanup while the repo ships a tmpdir() fixture with await using auto-cleanup at test/fixture/fixture.ts:147. The manual version leaks temp directories whenever a test throws mid-run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants