feat(feedback): deliver shared diagnostic reports to Chatwoot support inbox (#88)

* feat(feedback): deliver shared diagnostic reports to Chatwoot support inbox

Wire shareReport() to the Chatwoot public client API
(/public/api/v1/inboxes/{inbox_identifier}) so user-shared diagnostic
reports also reach the OpenCode Mobile Feedback inbox.

- New src/lib/chatwoot.ts: dependency-injected, node-testable client —
  anonymous contact -> conversation -> message. Ships only the inbox
  identifier (EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER); never an
  account api_access_token. Contact source_id persisted via
  SecureStore for conversation continuity; stale id recreated on 404.
- Delivery is gated on the same telemetry consent flag as
  Sentry/PostHog and is best-effort (share sheet never blocks on it).
- Reports are scrubbed before leaving the device: all URLs and every
  occurrence of the target host redacted (new redactHostAndUrls in
  scrub.ts).
- CI: pass EXPO_PUBLIC_CHATWOOT_INBOX_IDENTIFIER in build and
  Play-publish workflows. Deliberately NOT added to the F-Droid
  workflow to avoid widening reproducible-build divergence (#86).
- Consent modal copy discloses support-inbox delivery.

Closes #85

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(feedback): close host-leak gaps in support-report scrubbing

Security review findings on the Chatwoot delivery path:

- Log-buffer lines record server hosts without a scheme, which the
  URL regex never matches, and crash reports carry no host of their
  own — so bare hostnames could reach the support inbox. Track every
  host probed this session and redact them all in the support copy.
- Redact bare IPv4 addresses as a catch-all for hosts never parsed.
- Resolve telemetry consent from SecureStore when a report is shared
  before startup finished loading it, instead of silently dropping.
- Move redactHostAndUrls tests to scrub.test.ts alongside the module.

Refs #85

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Den
2026-07-16 23:08:40 -07:00
committed by GitHub
parent 004fa13795
commit 0bedad366b
8 changed files with 369 additions and 4 deletions

View File

@@ -34,6 +34,7 @@ jobs:
env: env:
EXPO_PUBLIC_SENTRY_DSN: ${{ secrets.EXPO_PUBLIC_SENTRY_DSN }} EXPO_PUBLIC_SENTRY_DSN: ${{ secrets.EXPO_PUBLIC_SENTRY_DSN }}
EXPO_PUBLIC_POSTHOG_KEY: ${{ secrets.EXPO_PUBLIC_POSTHOG_KEY }} 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_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}

View File

@@ -32,6 +32,7 @@ jobs:
env: env:
EXPO_PUBLIC_SENTRY_DSN: ${{ secrets.EXPO_PUBLIC_SENTRY_DSN }} EXPO_PUBLIC_SENTRY_DSN: ${{ secrets.EXPO_PUBLIC_SENTRY_DSN }}
EXPO_PUBLIC_POSTHOG_KEY: ${{ secrets.EXPO_PUBLIC_POSTHOG_KEY }} 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_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}

View File

@@ -33,8 +33,9 @@ export function TelemetryConsentModal({ visible, onAllow, onDecline }: Props) {
{/* Body */} {/* Body */}
<Text style={[styles.body, isDark && styles.bodyDark]}> <Text style={[styles.body, isDark && styles.bodyDark]}>
Share anonymous crash reports to help us find and fix bugs faster. No code, prompts, Share anonymous crash reports to help us find and fix bugs faster. Diagnostic reports
or server addresses are ever included. you share are also delivered to our support inbox. No code, prompts, or server
addresses are ever included.
</Text> </Text>
{/* Detail bullets */} {/* Detail bullets */}

131
src/lib/chatwoot.test.ts Normal file
View File

@@ -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<string, unknown>
}
// 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`)
})

143
src/lib/chatwoot.ts Normal file
View File

@@ -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<string | null>
saveSourceId?: (id: string) => Promise<void>
}
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<string, unknown>,
): Promise<{ status: number; json: Record<string, unknown> | 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<string, unknown> | null = null
try {
json = (await res.json()) as Record<string, unknown>
} catch {
// non-JSON error body
}
return { status: res.status, json }
} finally {
clearTimeout(timer)
}
}
async function createContact(fetchFn: typeof fetch, cfg: ChatwootConfig): Promise<string> {
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<number> {
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<void> {
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<boolean> {
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
}

View File

@@ -4,9 +4,13 @@
import { Platform, Share } from "react-native" import { Platform, Share } from "react-native"
import * as Clipboard from "expo-clipboard" import * as Clipboard from "expo-clipboard"
import * as Device from "expo-device" import * as Device from "expo-device"
import * as SecureStore from "expo-secure-store"
import appJson from "../../app.json" import appJson from "../../app.json"
import { log, formatLogLines } from "./logbuffer" import { log, formatLogLines } from "./logbuffer"
import { type Classification, type ProbeAttempt, type ParsedUrl, parseUrl, classify } from "./diagnostics-classify" 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" 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<string>()
export async function probeConnection(url: string, auth?: { username: string; password: string }): Promise<DiagnosticReport> { export async function probeConnection(url: string, auth?: { username: string; password: string }): Promise<DiagnosticReport> {
const parsed = parseUrl(url) const parsed = parseUrl(url)
if (parsed.host) seenHosts.add(parsed.host)
log.info("diag", "probe start", url, "parsed", JSON.stringify(parsed)) log.info("diag", "probe start", url, "parsed", JSON.stringify(parsed))
const headers: Record<string, string> = {} const headers: Record<string, string> = {}
@@ -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<void> {
// 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. // 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<void> { export async function shareReport(report: DiagnosticReport): Promise<void> {
const text = formatReport(report) const text = formatReport(report)
void sendReportToSupport(report)
try { try {
await Clipboard.setStringAsync(text) await Clipboard.setStringAsync(text)
} catch { } catch {

View File

@@ -1,6 +1,6 @@
import { test } from "node:test" import { test } from "node:test"
import assert from "node:assert/strict" import assert from "node:assert/strict"
import { scrubUrl, scrubString, scrubObject } from "./scrub.ts" import { scrubUrl, scrubString, scrubObject, redactHostAndUrls } from "./scrub.ts"
// scrubUrl ---------------------------------------------------------------- // scrubUrl ----------------------------------------------------------------
@@ -103,3 +103,36 @@ test("scrubObject: preserves non-string, non-object values as-is", () => {
assert.deepEqual(result.items, [1, 2, 3]) assert.deepEqual(result.items, [1, 2, 3])
assert.equal(result.nothing, null) 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("<redacted-url>"))
assert.ok(out.includes("host=<redacted-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("<redacted-ip>"))
})

View File

@@ -15,6 +15,19 @@ export function scrubString(s: string): string {
return s.replace(/https?:\/\/\S+/g, (m) => scrubUrl(m)) 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 | undefined>): string {
let out = text.replace(/https?:\/\/[^\s)\]}"']+/gi, "<redacted-url>")
for (const host of hosts ?? []) {
if (host) out = out.split(host).join("<redacted-host>")
}
out = out.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "<redacted-ip>")
return out
}
export function scrubObject(obj: Record<string, unknown>): Record<string, unknown> { export function scrubObject(obj: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {} const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(obj)) { for (const [k, v] of Object.entries(obj)) {