diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 173806a..00879bc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,6 +34,7 @@ jobs: env: EXPO_PUBLIC_SENTRY_DSN: ${{ secrets.EXPO_PUBLIC_SENTRY_DSN }} EXPO_PUBLIC_POSTHOG_KEY: ${{ secrets.EXPO_PUBLIC_POSTHOG_KEY }} + EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER: ${{ secrets.EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} diff --git a/.github/workflows/publish-play-store.yml b/.github/workflows/publish-play-store.yml index 36ec044..41361fa 100644 --- a/.github/workflows/publish-play-store.yml +++ b/.github/workflows/publish-play-store.yml @@ -32,6 +32,7 @@ jobs: env: EXPO_PUBLIC_SENTRY_DSN: ${{ secrets.EXPO_PUBLIC_SENTRY_DSN }} EXPO_PUBLIC_POSTHOG_KEY: ${{ secrets.EXPO_PUBLIC_POSTHOG_KEY }} + EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER: ${{ secrets.EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} diff --git a/src/components/TelemetryConsentModal.tsx b/src/components/TelemetryConsentModal.tsx index cc7fe80..97251f1 100644 --- a/src/components/TelemetryConsentModal.tsx +++ b/src/components/TelemetryConsentModal.tsx @@ -33,8 +33,9 @@ export function TelemetryConsentModal({ visible, onAllow, onDecline }: Props) { {/* Body */} - Share anonymous crash reports to help us find and fix bugs faster. No code, prompts, - or server addresses are ever included. + Share anonymous crash reports to help us find and fix bugs faster. Diagnostic reports + you share are also delivered to our support inbox. No code, prompts, or server + addresses are ever included. {/* Detail bullets */} diff --git a/src/lib/chatwoot.test.ts b/src/lib/chatwoot.test.ts new file mode 100644 index 0000000..50299a9 --- /dev/null +++ b/src/lib/chatwoot.test.ts @@ -0,0 +1,131 @@ +import { test, beforeEach, afterEach } from "node:test" +import assert from "node:assert/strict" +import { chatwootConfigured, sendSupportReport } from "./chatwoot.ts" + +const INBOX = "inbox-token-abc" +const BASE = "https://support.example.com" + +const savedEnv = { ...process.env } + +beforeEach(() => { + process.env.EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER = INBOX + process.env.EXPO_PUBLIC_CHATWOOT_BASE_URL = BASE +}) + +afterEach(() => { + process.env = { ...savedEnv } +}) + +interface Call { + url: string + body: Record +} + +// Sequenced fetch mock: each entry answers one call, in order. +function mockFetch(responses: Array<{ status: number; json?: unknown }>) { + const calls: Call[] = [] + const fetchFn = (async (url: RequestInfo | URL, init?: RequestInit) => { + calls.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) }) + const next = responses[calls.length - 1] ?? { status: 500 } + return { + status: next.status, + json: async () => { + if (next.json === undefined) throw new Error("no body") + return next.json + }, + } as Response + }) as typeof fetch + return { fetchFn, calls } +} + +test("chatwootConfigured false without inbox identifier", () => { + delete process.env.EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER + assert.equal(chatwootConfigured(), false) +}) + +test("sendSupportReport returns false when unconfigured, makes no calls", async () => { + delete process.env.EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER + const { fetchFn, calls } = mockFetch([]) + assert.equal(await sendSupportReport("hello", { fetchFn }), false) + assert.equal(calls.length, 0) +}) + +test("happy path: contact -> conversation -> message, source_id saved", async () => { + const { fetchFn, calls } = mockFetch([ + { status: 200, json: { source_id: "src-1" } }, + { status: 200, json: { id: 42 } }, + { status: 200, json: {} }, + ]) + let saved: string | null = null + const ok = await sendSupportReport("report body", { + fetchFn, + loadSourceId: async () => null, + saveSourceId: async (id) => { + saved = id + }, + }) + assert.equal(ok, true) + assert.equal(saved, "src-1") + assert.deepEqual( + calls.map((c) => c.url), + [ + `${BASE}/public/api/v1/inboxes/${INBOX}/contacts`, + `${BASE}/public/api/v1/inboxes/${INBOX}/contacts/src-1/conversations`, + `${BASE}/public/api/v1/inboxes/${INBOX}/contacts/src-1/conversations/42/messages`, + ], + ) + assert.equal(calls[2].body.content, "report body") +}) + +test("stored source_id skips contact creation", async () => { + const { fetchFn, calls } = mockFetch([ + { status: 200, json: { id: 7 } }, + { status: 200, json: {} }, + ]) + const ok = await sendSupportReport("hi", { fetchFn, loadSourceId: async () => "src-old" }) + assert.equal(ok, true) + assert.equal(calls.length, 2) + assert.match(calls[0].url, /contacts\/src-old\/conversations$/) +}) + +test("stale source_id (404) recreates contact once and retries", async () => { + const { fetchFn, calls } = mockFetch([ + { status: 404, json: { error: "resource could not be found" } }, + { status: 200, json: { source_id: "src-new" } }, + { status: 200, json: { id: 9 } }, + { status: 200, json: {} }, + ]) + let saved: string | null = null + const ok = await sendSupportReport("hi", { + fetchFn, + loadSourceId: async () => "src-stale", + saveSourceId: async (id) => { + saved = id + }, + }) + assert.equal(ok, true) + assert.equal(saved, "src-new") + assert.equal(calls.length, 4) + assert.match(calls[3].url, /contacts\/src-new\/conversations\/9\/messages$/) +}) + +test("contact create failure throws", async () => { + const { fetchFn } = mockFetch([{ status: 500, json: { error: "boom" } }]) + await assert.rejects(() => sendSupportReport("hi", { fetchFn, loadSourceId: async () => null }), /http 500/) +}) + +test("non-404 conversation failure does not recreate contact", async () => { + const { fetchFn, calls } = mockFetch([{ status: 500 }]) + await assert.rejects(() => sendSupportReport("hi", { fetchFn, loadSourceId: async () => "src-x" }), /http 500/) + assert.equal(calls.length, 1) +}) + +test("base url trailing slash is normalised", async () => { + process.env.EXPO_PUBLIC_CHATWOOT_BASE_URL = `${BASE}///` + const { fetchFn, calls } = mockFetch([ + { status: 200, json: { id: 1 } }, + { status: 200, json: {} }, + ]) + await sendSupportReport("hi", { fetchFn, loadSourceId: async () => "s" }) + assert.equal(calls[0].url, `${BASE}/public/api/v1/inboxes/${INBOX}/contacts/s/conversations`) +}) diff --git a/src/lib/chatwoot.ts b/src/lib/chatwoot.ts new file mode 100644 index 0000000..75b8c02 --- /dev/null +++ b/src/lib/chatwoot.ts @@ -0,0 +1,143 @@ +// Chatwoot support-feedback client using the PUBLIC client API +// (/public/api/v1/inboxes/{inbox_identifier}/...), which is designed for +// untrusted clients: the only value shipped in the binary is the inbox +// identifier. Never put an account-level api_access_token in this app — +// that token can read/write the whole support account. +// +// Contacts are created anonymously (no identifier), so Chatwoot's HMAC +// identifier-validation does not apply. The returned contact source_id is +// persisted via injected storage hooks so repeat reports from the same +// install land on the same contact. +// +// This module is dependency-injected and imports no React Native / Expo +// packages so it runs under `node --test`. + +const REQUEST_TIMEOUT_MS = 15_000 + +export interface ChatwootDeps { + fetchFn?: typeof fetch + loadSourceId?: () => Promise + saveSourceId?: (id: string) => Promise +} + +interface ChatwootConfig { + baseUrl: string + inboxIdentifier: string +} + +export function getChatwootConfig(): ChatwootConfig | null { + const inboxIdentifier = process.env.EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER + if (!inboxIdentifier) return null + const baseUrl = (process.env.EXPO_PUBLIC_CHATWOOT_BASE_URL || "https://support.agentlabs.cc").replace(/\/+$/, "") + return { baseUrl, inboxIdentifier } +} + +export function chatwootConfigured(): boolean { + return getChatwootConfig() !== null +} + +async function post( + fetchFn: typeof fetch, + url: string, + body: Record, +): Promise<{ status: number; json: Record | null }> { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) + try { + const res = await fetchFn(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: controller.signal, + }) + let json: Record | null = null + try { + json = (await res.json()) as Record + } catch { + // non-JSON error body + } + return { status: res.status, json } + } finally { + clearTimeout(timer) + } +} + +async function createContact(fetchFn: typeof fetch, cfg: ChatwootConfig): Promise { + const { status, json } = await post(fetchFn, `${cfg.baseUrl}/public/api/v1/inboxes/${cfg.inboxIdentifier}/contacts`, { + name: "OpenCode Mobile user", + }) + const sourceId = json?.source_id + if (status >= 400 || typeof sourceId !== "string" || !sourceId) { + throw new Error(`chatwoot contact create failed (http ${status})`) + } + return sourceId +} + +async function createConversation(fetchFn: typeof fetch, cfg: ChatwootConfig, sourceId: string): Promise { + const { status, json } = await post( + fetchFn, + `${cfg.baseUrl}/public/api/v1/inboxes/${cfg.inboxIdentifier}/contacts/${encodeURIComponent(sourceId)}/conversations`, + {}, + ) + const id = json?.id + if (status >= 400 || typeof id !== "number") { + const err = new Error(`chatwoot conversation create failed (http ${status})`) + ;(err as Error & { status?: number }).status = status + throw err + } + return id +} + +async function postMessage( + fetchFn: typeof fetch, + cfg: ChatwootConfig, + sourceId: string, + conversationId: number, + content: string, +): Promise { + const { status } = await post( + fetchFn, + `${cfg.baseUrl}/public/api/v1/inboxes/${cfg.inboxIdentifier}/contacts/${encodeURIComponent(sourceId)}/conversations/${conversationId}/messages`, + { content }, + ) + if (status >= 400) throw new Error(`chatwoot message post failed (http ${status})`) +} + +/** + * Send an already-scrubbed report to the support inbox as a new conversation. + * Returns true on success, false when Chatwoot is not configured. + * Throws on network/API failure — callers decide whether that is fatal + * (the in-app share path treats it as best-effort). + * + * Consent is NOT checked here; this module has no access to the consent + * store. Callers MUST gate on telemetry consent before invoking. + */ +export async function sendSupportReport(content: string, deps: ChatwootDeps = {}): Promise { + const cfg = getChatwootConfig() + if (!cfg) return false + const fetchFn = deps.fetchFn ?? fetch + + let sourceId = (await deps.loadSourceId?.().catch(() => null)) ?? null + let created = false + if (!sourceId) { + sourceId = await createContact(fetchFn, cfg) + created = true + } + + let conversationId: number + try { + conversationId = await createConversation(fetchFn, cfg, sourceId) + } catch (error) { + const status = (error as { status?: number }).status + // A persisted source_id can go stale (contact deleted server-side). + // Recreate the contact once and retry. + if (created || status !== 404) throw error + sourceId = await createContact(fetchFn, cfg) + created = true + conversationId = await createConversation(fetchFn, cfg, sourceId) + } + + await postMessage(fetchFn, cfg, sourceId, conversationId, content) + if (created) await deps.saveSourceId?.(sourceId).catch(() => undefined) + return true +} diff --git a/src/lib/diagnostics.ts b/src/lib/diagnostics.ts index 2efe58a..2ce030a 100644 --- a/src/lib/diagnostics.ts +++ b/src/lib/diagnostics.ts @@ -4,9 +4,13 @@ import { Platform, Share } from "react-native" import * as Clipboard from "expo-clipboard" import * as Device from "expo-device" +import * as SecureStore from "expo-secure-store" import appJson from "../../app.json" import { log, formatLogLines } from "./logbuffer" import { type Classification, type ProbeAttempt, type ParsedUrl, parseUrl, classify } from "./diagnostics-classify" +import { chatwootConfigured, sendSupportReport } from "./chatwoot" +import { hasTelemetryConsent, loadTelemetryConsent } from "./telemetry" +import { redactHostAndUrls } from "./scrub" export type { Classification, ProbeAttempt } from "./diagnostics-classify" @@ -55,8 +59,14 @@ async function timedFetch(name: string, target: string, init?: RequestInit): Pro } } +// Every host probed this session. Log lines mention hosts without a scheme +// (so URL-based scrubbing misses them), and a crash report has no host of its +// own — this set lets formatReportForSupport redact them all regardless. +const seenHosts = new Set() + export async function probeConnection(url: string, auth?: { username: string; password: string }): Promise { const parsed = parseUrl(url) + if (parsed.host) seenHosts.add(parsed.host) log.info("diag", "probe start", url, "parsed", JSON.stringify(parsed)) const headers: Record = {} @@ -160,10 +170,42 @@ export function buildCrashReport(error: unknown, source: "react-boundary" | "glo } } +// Variant of formatReport for sending off-device: the user's server address +// must never leave the phone, so every URL and every occurrence of the target +// host is redacted. Classification, probe outcomes, device info and (URL- +// scrubbed) logs survive — that is what support needs. +export function formatReportForSupport(report: DiagnosticReport): string { + return redactHostAndUrls(formatReport(report), [report.host, ...seenHosts]) +} + +const CHATWOOT_SOURCE_KEY = "opencode_chatwoot_source_id" + +// Best-effort delivery of the scrubbed report to the Chatwoot support inbox. +// Only runs when the user has granted telemetry consent (same flag that +// gates Sentry/analytics) and the inbox is configured for this build. +async function sendReportToSupport(report: DiagnosticReport): Promise { + // Consent may not be loaded yet if a crash happens very early in startup — + // resolve it from the store rather than silently dropping the report. + if (hasTelemetryConsent() === null) await loadTelemetryConsent() + if (hasTelemetryConsent() !== true || !chatwootConfigured()) return + try { + await sendSupportReport(formatReportForSupport(report), { + loadSourceId: () => SecureStore.getItemAsync(CHATWOOT_SOURCE_KEY), + saveSourceId: (id) => SecureStore.setItemAsync(CHATWOOT_SOURCE_KEY, id), + }) + log.info("diag", "report delivered to support inbox") + } catch (e) { + log.warn("diag", "support delivery failed", String(e)) + } +} + // Copy the report to the clipboard and open the native share sheet. -// Works fully offline (unlike the Sentry auto-upload). +// Works fully offline (unlike the Sentry auto-upload). When telemetry +// consent is granted, a scrubbed copy is also delivered to the support +// inbox so reports reach us even if the user cancels the share sheet. export async function shareReport(report: DiagnosticReport): Promise { const text = formatReport(report) + void sendReportToSupport(report) try { await Clipboard.setStringAsync(text) } catch { diff --git a/src/lib/scrub.test.ts b/src/lib/scrub.test.ts index 30b2b65..438ec21 100644 --- a/src/lib/scrub.test.ts +++ b/src/lib/scrub.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test" import assert from "node:assert/strict" -import { scrubUrl, scrubString, scrubObject } from "./scrub.ts" +import { scrubUrl, scrubString, scrubObject, redactHostAndUrls } from "./scrub.ts" // scrubUrl ---------------------------------------------------------------- @@ -103,3 +103,36 @@ test("scrubObject: preserves non-string, non-object values as-is", () => { assert.deepEqual(result.items, [1, 2, 3]) assert.equal(result.nothing, null) }) + +// redactHostAndUrls ------------------------------------------------------- + +test("redactHostAndUrls: strips URLs, bare host occurrences, credentials", () => { + const host = "my-dev-box.tail1234.ts.net" + const text = [ + `Target URL: https://user:pw@${host}:4096/api`, + ` scheme=https host=${host} port=4096 hostname=true`, + `probe start http://${host}:4096/global/health`, + "internet https://www.gstatic.com/generate_204 OK", + ].join("\n") + const out = redactHostAndUrls(text, [host]) + assert.ok(!out.includes(host), "host must not survive") + assert.ok(!out.includes("user:pw"), "credentials must not survive") + assert.ok(out.includes("")) + assert.ok(out.includes("host=")) +}) + +test("redactHostAndUrls: redacts every session host, not just the report's own", () => { + // Crash-report case: report.host is undefined but earlier failed-connect + // log lines mention hosts without a scheme. + const text = ['{"host":"box-a.tailnet.ts.net","port":"4096"}', "server unreachable box-b.local:8080"].join("\n") + const out = redactHostAndUrls(text, [undefined, "box-a.tailnet.ts.net", "box-b.local"]) + assert.ok(!out.includes("box-a.tailnet.ts.net")) + assert.ok(!out.includes("box-b.local")) +}) + +test("redactHostAndUrls: blanks bare IPv4 addresses even when unknown", () => { + const out = redactHostAndUrls("connect failed 192.168.1.50:4096 via 10.0.0.1", []) + assert.ok(!out.includes("192.168.1.50")) + assert.ok(!out.includes("10.0.0.1")) + assert.ok(out.includes("")) +}) diff --git a/src/lib/scrub.ts b/src/lib/scrub.ts index b75038e..172994e 100644 --- a/src/lib/scrub.ts +++ b/src/lib/scrub.ts @@ -15,6 +15,19 @@ export function scrubString(s: string): string { return s.replace(/https?:\/\/\S+/g, (m) => scrubUrl(m)) } +// Harder redaction for text that leaves the device (support inbox): drop +// every URL wholesale, erase every known server host (bare `host=…` fragments +// and log lines carry hosts without a scheme, which the URL regex misses), +// and blank bare IPv4 addresses as a catch-all for hosts we never parsed. +export function redactHostAndUrls(text: string, hosts?: Array): string { + let out = text.replace(/https?:\/\/[^\s)\]}"']+/gi, "") + for (const host of hosts ?? []) { + if (host) out = out.split(host).join("") + } + out = out.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "") + return out +} + export function scrubObject(obj: Record): Record { const out: Record = {} for (const [k, v] of Object.entries(obj)) {