From 51d440a0b88a8f23e8a3c17d3629ff8373d1da5c Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Mon, 27 Jul 2026 09:55:41 +0300 Subject: [PATCH 1/5] fix: attach email to Sentry user so operator alerts name the human (AIT-278) decodeJwtClaim generalizes the payload decode; setCliUserFromCreds now sets user.email + user.id/user.email tags (Slack alert rules render tags only). --- src/observability/jwt-light.ts | 15 ++++++++++++--- src/observability/sentry.ts | 14 +++++++++++--- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/observability/jwt-light.ts b/src/observability/jwt-light.ts index 56372f9..b3653a6 100644 --- a/src/observability/jwt-light.ts +++ b/src/observability/jwt-light.ts @@ -8,7 +8,7 @@ // // Safe failure: malformed token → empty string → Sentry.setUser skips (the // observability/sentry.ts caller guards on empty sub). -export function decodeJwtSub(token: string): string { +function decodeJwtClaim(token: string, claim: string): string { try { const parts = token.split('.'); if (parts.length < 2) return ''; @@ -17,9 +17,18 @@ export function decodeJwtSub(token: string): string { const payload = JSON.parse( Buffer.from(payloadB64, 'base64url').toString('utf8'), ) as Record; - const sub = payload.sub; - return typeof sub === 'string' ? sub : ''; + const value = payload[claim]; + return typeof value === 'string' ? value : ''; } catch { return ''; } } + +export function decodeJwtSub(token: string): string { + return decodeJwtClaim(token, 'sub'); +} + +// Operator alerts must name the human, not just the WorkOS id (AIT-278). +export function decodeJwtEmail(token: string): string { + return decodeJwtClaim(token, 'email'); +} diff --git a/src/observability/sentry.ts b/src/observability/sentry.ts index 184afd2..8d6eba5 100644 --- a/src/observability/sentry.ts +++ b/src/observability/sentry.ts @@ -48,7 +48,7 @@ // CLI error pre-0.11.0 was caused by violating this rule. import { isTelemetryEnabled, maybePrintFirstRunDisclosure } from './telemetry.js'; -import { decodeJwtSub } from './jwt-light.js'; +import { decodeJwtSub, decodeJwtEmail } from './jwt-light.js'; import { shutdownPostHog } from './posthog.js'; import { getConfigDir } from '../storage/path.js'; @@ -213,8 +213,16 @@ export async function setCliUserFromCreds(): Promise { if (!creds?.accessToken) return; const sub = decodeJwtSub(creds.accessToken); - if (!sub) return; - sentryModule.setUser({ id: sub }); + const email = decodeJwtEmail(creds.accessToken); + if (!sub && !email) return; + sentryModule.setUser({ + ...(sub ? { id: sub } : {}), + ...(email ? { email } : {}), + }); + // Tags too: Slack alert rules render user.email/user.id tags, not the + // user context (AIT-278). + if (sub) sentryModule.setTag('user.id', sub); + if (email) sentryModule.setTag('user.email', email); } /** From c7a8bc1982827065f33bdf68aa8714b33d2cc0aa Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Mon, 27 Jul 2026 10:00:21 +0300 Subject: [PATCH 2/5] docs: disclose email in telemetry section (AIT-278) --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 27b4879..58feec7 100644 --- a/README.md +++ b/README.md @@ -311,7 +311,8 @@ expects this token echoed back in the response body. HookMyApp CLI reports crashes to our Sentry project so we can fix bugs fast. **No command arguments, file contents, or environment variable values are sent.** Only the error class, stack trace, CLI version, platform, and (when -you are logged in) your WorkOS user id are reported. +you are logged in) your WorkOS user id and account email are reported — the +email lets us match a crash report to your support request. Telemetry is ON by default — industry norm for product CLIs (npm, Next.js, Vercel, Homebrew). You can disable it any time: From 9170059c5ee0cf1f0c18c2e70ea2e2e0e8c5c5e4 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Mon, 27 Jul 2026 10:07:14 +0300 Subject: [PATCH 3/5] fix: clear stale Sentry identity when credentials change or lack claims (AIT-278) Codex review P1: early return left a previous login's user context + tags attached after credential changes in long-running commands. Identity is now always applied or cleared from current credentials. --- src/observability/sentry.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/observability/sentry.ts b/src/observability/sentry.ts index 8d6eba5..5460a0a 100644 --- a/src/observability/sentry.ts +++ b/src/observability/sentry.ts @@ -210,19 +210,19 @@ export async function setCliUserFromCreds(): Promise { // touch credentials (--help, --version). const { readCredentials } = await import('../auth/store.js'); const creds = await readCredentials(); - if (!creds?.accessToken) return; - - const sub = decodeJwtSub(creds.accessToken); - const email = decodeJwtEmail(creds.accessToken); - if (!sub && !email) return; - sentryModule.setUser({ + const token = creds?.accessToken ?? ''; + const sub = token ? decodeJwtSub(token) : ''; + const email = token ? decodeJwtEmail(token) : ''; + // Always apply (or clear) — an early return here would leave a previous + // login's identity attached after a credential change mid-process. + sentryModule.setUser(sub || email ? { ...(sub ? { id: sub } : {}), ...(email ? { email } : {}), - }); + } : null); // Tags too: Slack alert rules render user.email/user.id tags, not the - // user context (AIT-278). - if (sub) sentryModule.setTag('user.id', sub); - if (email) sentryModule.setTag('user.email', email); + // user context (AIT-278). `undefined` removes the tag. + sentryModule.setTag('user.id', sub || undefined); + sentryModule.setTag('user.email', email || undefined); } /** From c9dbbe4cbd0618c94719ffb8b282619d9d160857 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Mon, 27 Jul 2026 10:13:52 +0300 Subject: [PATCH 4/5] fix: attribute pre-API errors and agent-credential logins in crash reports (AIT-278) Codex review round 3: - captureError attaches identity itself (best-effort) so failures before the first API call still name the user. - Agent (hmok_) tokens are opaque: persist the login email with the credential and fall back to it in setCliUserFromCreds. - First-run telemetry banner now discloses email + user id collection. --- src/auth/login.ts | 1 + src/observability/sentry.ts | 7 ++++++- src/observability/telemetry.ts | 1 + src/storage/secrets.ts | 3 +++ 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/auth/login.ts b/src/auth/login.ts index f4aadcd..c3b5421 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -584,6 +584,7 @@ async function persistAgentCredential( kind: 'agent', credentialPublicId: cred.credentialPublicId, scopes: cred.scopes, + email, }); await revalidateActiveWorkspace(json); maybeInstallClaudeMcp(); diff --git a/src/observability/sentry.ts b/src/observability/sentry.ts index 5460a0a..6bca8a1 100644 --- a/src/observability/sentry.ts +++ b/src/observability/sentry.ts @@ -212,7 +212,8 @@ export async function setCliUserFromCreds(): Promise { const creds = await readCredentials(); const token = creds?.accessToken ?? ''; const sub = token ? decodeJwtSub(token) : ''; - const email = token ? decodeJwtEmail(token) : ''; + // Agent (hmok_) tokens are opaque — the login email is persisted alongside. + const email = (token ? decodeJwtEmail(token) : '') || creds?.email || ''; // Always apply (or clear) — an early return here would leave a previous // login's identity attached after a credential change mid-process. sentryModule.setUser(sub || email ? { @@ -269,6 +270,10 @@ export function shouldCaptureToSentry(err: any): boolean { export async function captureError(err: unknown): Promise { if (!initialized || !sentryModule) return; if (!shouldCaptureToSentry(err)) return; + // Attach identity here too, not only in apiClient — errors thrown before + // the first API call (local validation, arg handling) must still name the + // logged-in user in operator alerts (AIT-278). Best-effort. + try { await setCliUserFromCreds(); } catch { /* never block capture */ } try { // Tag with severity + code when available (AppError subclasses). if (err && typeof err === 'object') { diff --git a/src/observability/telemetry.ts b/src/observability/telemetry.ts index 33588ad..f815599 100644 --- a/src/observability/telemetry.ts +++ b/src/observability/telemetry.ts @@ -92,6 +92,7 @@ export function maybePrintFirstRunDisclosure(): void { '', 'ℹ Telemetry: HookMyApp CLI reports crashes + usage analytics to help us fix bugs and improve UX.', ' No command arguments, file contents, or env var values are sent.', + ' When logged in, your account email + user id accompany crash reports.', ' Disable: `hookmyapp config set telemetry off` or `HOOKMYAPP_TELEMETRY=off`', '', ].join('\n'), diff --git a/src/storage/secrets.ts b/src/storage/secrets.ts index 1a40552..da4ecb7 100644 --- a/src/storage/secrets.ts +++ b/src/storage/secrets.ts @@ -30,6 +30,9 @@ export interface Secrets { credentialPublicId?: string; /** Agent credentials only: scopes granted at issue time. */ scopes?: string[]; + /** Agent credentials only: login email — the hmok_ token is opaque, so this + * is the only human identity available for crash attribution (AIT-278). */ + email?: string; } /** True for an auth.md-issued org-scoped agent credential (no refresh token). */ From 3988f23042142b8e8b7afca65c83e997640354e9 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Mon, 27 Jul 2026 10:18:53 +0300 Subject: [PATCH 5/5] fix: re-show telemetry disclosure to upgraders before email collection (AIT-278) Versioned disclosure flag (v2 = email + user id); legacy bool counts as v1 so existing installs see the updated banner once. --- src/observability/telemetry.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/observability/telemetry.ts b/src/observability/telemetry.ts index f815599..132b9da 100644 --- a/src/observability/telemetry.ts +++ b/src/observability/telemetry.ts @@ -20,9 +20,14 @@ import { getConfigFile, safeWriteFileSync } from '../storage/path.js'; type TelemetryFlag = 'on' | 'off'; +// Bump when the disclosure text materially changes what is collected (v2: +// account email + user id, AIT-278) so existing installs see it again. +const DISCLOSURE_VERSION = 2; + interface Config { telemetry?: TelemetryFlag; telemetryDisclosureShown?: boolean; + telemetryDisclosureVersion?: number; // Other existing keys (activeWorkspaceId, activeWorkspaceSlug, env, etc.) // are preserved by the read+merge+write pattern below. [key: string]: unknown; @@ -86,7 +91,9 @@ export function unsetPersistedTelemetry(): void { */ export function maybePrintFirstRunDisclosure(): void { const cfg = readConfig(); - if (cfg.telemetryDisclosureShown) return; + // Legacy bool counts as v1 — upgraders must see the v2 (email) disclosure. + const seen = cfg.telemetryDisclosureVersion ?? (cfg.telemetryDisclosureShown ? 1 : 0); + if (seen >= DISCLOSURE_VERSION) return; process.stderr.write( [ '', @@ -98,5 +105,6 @@ export function maybePrintFirstRunDisclosure(): void { ].join('\n'), ); cfg.telemetryDisclosureShown = true; + cfg.telemetryDisclosureVersion = DISCLOSURE_VERSION; writeConfig(cfg); }