diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index d7b66751d04..60970b32a4d 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { useCallback, useEffect, useMemo } from "react"; import { CommandId, MessageId, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; @@ -231,7 +232,12 @@ export function useThreadComposerState() { appendComposerDraftAttachments(threadKey, images); } } catch (error) { - console.error("[native paste] error converting images", error); + console.error("[native paste] error converting images", { + environmentId: selectedThreadShell.environmentId, + threadId: selectedThreadShell.id, + uriCount: uris.length, + ...safeErrorLogAttributes(error), + }); } }, [composerDrafts, selectedThreadShell], diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index ae356fe08ef..f39af581d5a 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -4,6 +4,7 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import type { ScopedThreadRef, TurnId } from "@t3tools/contracts"; import { ArrowRightIcon, @@ -452,7 +453,16 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff void (async () => { const result = await openInPreferredEditor(targetPath); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - console.warn("Failed to open diff file in editor.", squashAtomCommandFailure(result)); + console.warn("Failed to open diff file in editor.", { + operation: "open-diff-file", + ...(routeThreadRef + ? { + environmentId: routeThreadRef.environmentId, + threadId: routeThreadRef.threadId, + } + : {}), + ...safeErrorLogAttributes(squashAtomCommandFailure(result)), + }); } })(); }, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f6eb8602cf8..f3ed88bd3b9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -55,6 +55,7 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { isAtomCommandInterrupted, settlePromise, @@ -1523,7 +1524,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec console.error("Failed to remove project", { projectId: member.id, environmentId: member.environmentId, - error, + ...safeErrorLogAttributes(error), }); toastManager.add( stackedThreadToast({ @@ -1558,7 +1559,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec console.error("Failed to remove project", { projectId: member.id, environmentId: member.environmentId, - error, + ...safeErrorLogAttributes(error), }); toastManager.add( stackedThreadToast({ diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 6e08fa68ef1..994cbb08f23 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -12,6 +12,7 @@ import { type ScopedThreadRef, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { isAtomCommandInterrupted, settlePromise, @@ -1038,7 +1039,11 @@ export function ProviderSettingsPanel() { refreshingRef.current = false; setIsRefreshingProviders(false); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - console.warn("Failed to refresh providers", squashAtomCommandFailure(result)); + console.warn("Failed to refresh providers", { + operation: "refresh-providers", + environmentId: primaryEnvironment.environmentId, + ...safeErrorLogAttributes(squashAtomCommandFailure(result)), + }); } })(); }, [primaryEnvironment, refreshServerProviders]); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index bf8b3a7dd08..514484d896a 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -23,6 +23,7 @@ import { DEFAULT_CLIENT_SETTINGS, type UnifiedSettings, } from "@t3tools/contracts/settings"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { ensureLocalApi } from "~/localApi"; import * as Struct from "effect/Struct"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; @@ -106,7 +107,10 @@ async function hydrateClientSettings(): Promise { replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings }); } } catch (error) { - console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error); + console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, { + operation: "hydrate", + ...safeErrorLogAttributes(error), + }); } finally { if (hydrationGeneration === clientSettingsHydrationGeneration) { setClientSettingsHydrated(true); @@ -129,7 +133,10 @@ function persistClientSettings(settings: ClientSettings): void { void ensureLocalApi() .persistence.setClientSettings(settings) .catch((error) => { - console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, error); + console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, { + operation: "persist", + ...safeErrorLogAttributes(error), + }); }); } diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index 11045392bae..27d348019e2 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -7,6 +7,7 @@ import { HttpClient } from "effect/unstable/http"; import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; import { settleAsyncResult, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary"; import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; import { isElectron } from "../env"; @@ -95,9 +96,14 @@ async function applyClientTracingConfig(config: ClientTracingConfig): Promise 0) { - return error.message; - } - - return String(error); -} - export async function __resetClientTracingForTests() { configurationGeneration++; activeConfigKey = null; diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 99889916a9a..d9efcd4263a 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -26,6 +26,7 @@ import { type SupervisorConnectionState, } from "./model.ts"; import * as RpcSession from "../rpc/session.ts"; +import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import * as ConnectionWakeups from "./wakeups.ts"; const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const; @@ -503,11 +504,13 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( !establishment.exit.cause.reasons.some(Cause.isFailReason); const outcome = failureFromExit(target, establishment.exit, false, false); if (isUnexpectedDefect) { + const defect = establishment.exit.cause.reasons.find(Cause.isDieReason)?.defect; yield* Effect.logError("Connection attempt failed with an unexpected defect.").pipe( Effect.annotateLogs({ "environment.id": target.environmentId, "environment.label": target.label, - cause: Cause.pretty(establishment.exit.cause), + "cause.reason_count": establishment.exit.cause.reasons.length, + ...safeErrorLogAttributes(defect), }), ); } diff --git a/packages/client-runtime/src/errors/index.ts b/packages/client-runtime/src/errors/index.ts index a29060e6758..7eb6244e5a7 100644 --- a/packages/client-runtime/src/errors/index.ts +++ b/packages/client-runtime/src/errors/index.ts @@ -1,2 +1,3 @@ export * from "./errorTrace.ts"; +export * from "./safeLog.ts"; export * from "./transport.ts"; diff --git a/packages/client-runtime/src/errors/safeLog.test.ts b/packages/client-runtime/src/errors/safeLog.test.ts new file mode 100644 index 00000000000..b8dfb235ffe --- /dev/null +++ b/packages/client-runtime/src/errors/safeLog.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { safeErrorLogAttributes } from "./safeLog.ts"; + +describe("safeErrorLogAttributes", () => { + it("keeps correlation and stack frames without serializing messages or nested causes", () => { + const cause = Object.assign(new Error("nested-cause-secret-sentinel"), { + traceId: "trace-safe-123", + }); + const error = Object.assign(new Error("outer-error-secret-sentinel", { cause }), { + _tag: "ProjectRemovalError", + }); + error.stack = [ + "ProjectRemovalError: outer-error-secret-sentinel", + " at removeProject (https://user:password@example.com/project.ts?token=secret#fragment)", + ].join("\n"); + + const attributes = safeErrorLogAttributes(error); + + expect(attributes).toMatchObject({ + errorType: "error", + errorName: "Error", + errorTag: "ProjectRemovalError", + traceId: "trace-safe-123", + stack: " at removeProject (https://example.com/project.ts)", + }); + const diagnosticText = Object.values(attributes).map(String).join("\n"); + expect(diagnosticText).not.toContain("outer-error-secret-sentinel"); + expect(diagnosticText).not.toContain("nested-cause-secret-sentinel"); + expect(diagnosticText).not.toContain("user:password"); + expect(diagnosticText).not.toContain("token=secret"); + }); + + it("does not trust arbitrary object messages or tags", () => { + const attributes = safeErrorLogAttributes({ + _tag: "payload-secret-sentinel", + message: "message-secret-sentinel", + cause: { traceId: "trace id with unsafe whitespace" }, + }); + + expect(attributes).toEqual({ errorType: "object" }); + }); + + it("skips an unsafe outer trace id when a nested safe trace id is available", () => { + const attributes = safeErrorLogAttributes({ + traceId: "unsafe trace id", + cause: { traceId: "trace-safe-inner" }, + }); + + expect(attributes).toEqual({ + errorType: "object", + traceId: "trace-safe-inner", + }); + }); +}); diff --git a/packages/client-runtime/src/errors/safeLog.ts b/packages/client-runtime/src/errors/safeLog.ts new file mode 100644 index 00000000000..60730d4d35c --- /dev/null +++ b/packages/client-runtime/src/errors/safeLog.ts @@ -0,0 +1,107 @@ +const SAFE_ERROR_LABEL = + /^(?:Error|EvalError|RangeError|ReferenceError|SyntaxError|TypeError|URIError|AggregateError|DOMException|[A-Za-z][A-Za-z0-9]*(?:Error|Failure))$/; +const SAFE_TRACE_ID = /^[A-Za-z0-9._:-]{1,128}$/; +const STACK_FRAME_LIMIT = 32; + +export interface SafeErrorLogAttributes { + readonly errorType: "error" | "array" | "null" | "object" | "primitive"; + readonly errorName?: string; + readonly errorTag?: string; + readonly traceId?: string; + readonly stack?: string; +} + +function readSafeLabel(value: unknown): string | undefined { + return typeof value === "string" && SAFE_ERROR_LABEL.test(value) ? value : undefined; +} + +function sanitizeStackUrl(value: string): string { + try { + const url = new URL(value); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return value; + } +} + +function sanitizeStackFrame(frame: string): string { + return frame.replace(/(?:https?|file):\/\/[^\s)]+/g, sanitizeStackUrl); +} + +function readSafeStack(error: Error): string | undefined { + try { + const frames = error.stack + ?.split(/\r?\n/) + .filter((line) => /^\s*at\s+/.test(line) || /^[^@\s]+@(?:https?|file):\/\//.test(line)) + .slice(0, STACK_FRAME_LIMIT) + .map(sanitizeStackFrame); + return frames && frames.length > 0 ? frames.join("\n") : undefined; + } catch { + return undefined; + } +} + +function readErrorTag(error: unknown): string | undefined { + try { + if (typeof error !== "object" || error === null) { + return undefined; + } + return readSafeLabel((error as { readonly _tag?: unknown })._tag); + } catch { + return undefined; + } +} + +function readTraceId(error: unknown): string | undefined { + try { + const seen = new Set(); + let current: unknown = error; + + while (typeof current === "object" && current !== null && !seen.has(current)) { + seen.add(current); + const record = current as { readonly cause?: unknown; readonly traceId?: unknown }; + if (typeof record.traceId === "string" && SAFE_TRACE_ID.test(record.traceId)) { + return record.traceId; + } + current = record.cause; + } + + return undefined; + } catch { + return undefined; + } +} + +export function safeErrorLogAttributes(error: unknown): SafeErrorLogAttributes { + const errorTag = readErrorTag(error); + const traceId = readTraceId(error); + + if (error instanceof Error) { + const errorName = readSafeLabel(error.name); + const stack = readSafeStack(error); + return { + errorType: "error", + ...(errorName !== undefined ? { errorName } : {}), + ...(errorTag !== undefined ? { errorTag } : {}), + ...(traceId !== undefined ? { traceId } : {}), + ...(stack !== undefined ? { stack } : {}), + }; + } + + return { + errorType: + error === null + ? "null" + : Array.isArray(error) + ? "array" + : typeof error === "object" + ? "object" + : "primitive", + ...(errorTag !== undefined ? { errorTag } : {}), + ...(traceId !== undefined ? { traceId } : {}), + }; +} diff --git a/packages/client-runtime/src/state/archivedThreads.test.ts b/packages/client-runtime/src/state/archivedThreads.test.ts index 29679d00ffe..aa16b9cadcd 100644 --- a/packages/client-runtime/src/state/archivedThreads.test.ts +++ b/packages/client-runtime/src/state/archivedThreads.test.ts @@ -1,7 +1,10 @@ -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { expect, it } from "vite-plus/test"; import { + createArchivedThreadSnapshotsAtomFamily, makeArchivedThreadsEnvironmentKey, parseArchivedThreadsEnvironmentKey, } from "./archivedThreads.ts"; @@ -13,3 +16,25 @@ it("round-trips environment keys in sorted order", () => { expect(parseArchivedThreadsEnvironmentKey(key)).toEqual([envA, envB]); }); + +it("does not expose an archived snapshot failure message", () => { + const environmentId = EnvironmentId.make("env-sensitive"); + const snapshotsAtom = createArchivedThreadSnapshotsAtomFamily({ + getSnapshotAtom: () => + Atom.make( + AsyncResult.failure( + Cause.fail(new Error("credential=secret-value")), + ), + ), + labelPrefix: "test:archived-thread-snapshots", + }); + const registry = AtomRegistry.make(); + + expect(registry.get(snapshotsAtom(makeArchivedThreadsEnvironmentKey([environmentId])))).toEqual({ + snapshots: [], + error: "Failed to load archived threads.", + isLoading: false, + }); + + registry.dispose(); +}); diff --git a/packages/client-runtime/src/state/archivedThreads.ts b/packages/client-runtime/src/state/archivedThreads.ts index 7441d46cf32..8c64f1ae506 100644 --- a/packages/client-runtime/src/state/archivedThreads.ts +++ b/packages/client-runtime/src/state/archivedThreads.ts @@ -1,6 +1,5 @@ import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; import * as Arr from "effect/Array"; -import * as Cause from "effect/Cause"; import { pipe } from "effect/Function"; import * as Option from "effect/Option"; import * as Order from "effect/Order"; @@ -60,11 +59,7 @@ export function createArchivedThreadSnapshotsAtomFamily(options: { } if (error === null && result._tag === "Failure") { - const cause = Cause.squash(result.cause); - error = - cause instanceof Error && cause.message.trim().length > 0 - ? cause.message - : "Failed to load archived threads."; + error = "Failed to load archived threads."; } } diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 84e9dbdd8eb..3cb62009a20 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -8,6 +8,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { EnvironmentRegistry } from "../connection/registry.ts"; import type { PreparedConnection } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { followStreamInEnvironment } from "./runtime.ts"; export function initialConfigOption( @@ -16,9 +17,10 @@ export function initialConfigOption( return initialConfig.pipe( Effect.map(Option.some), Effect.catch((error) => - Effect.logWarning("Could not load the initial environment configuration.", { - error, - }).pipe(Effect.as(Option.none())), + Effect.logWarning("Could not load the initial environment configuration.").pipe( + Effect.annotateLogs({ ...safeErrorLogAttributes(error) }), + Effect.as(Option.none()), + ), ), ); } diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index 428e99b76d0..2b0ba6346f5 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -16,6 +16,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { EnvironmentRegistry } from "../connection/registry.ts"; import { connectionProjectionPhase } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { subscribe } from "../rpc/client.ts"; import { applyShellStreamEvent } from "./shellReducer.ts"; @@ -42,11 +43,7 @@ function shellStatusForSnapshot( return Option.isSome(snapshot) ? "cached" : "empty"; } -function formatShellError(error: unknown): string { - return error instanceof Error && error.message.trim().length > 0 - ? error.message - : "Could not synchronize environment data."; -} +const SHELL_SYNCHRONIZATION_ERROR_MESSAGE = "Could not synchronize environment data."; export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")(function* () { const supervisor = yield* EnvironmentSupervisor; @@ -57,7 +54,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") Effect.logWarning("Could not load cached environment shell.").pipe( Effect.annotateLogs({ environmentId, - error: error.message, + ...safeErrorLogAttributes(error), }), Effect.as(Option.none()), ), @@ -78,7 +75,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") Effect.logWarning("Could not persist environment shell cache.").pipe( Effect.annotateLogs({ environmentId, - error: error.message, + ...safeErrorLogAttributes(error), }), ), ), @@ -110,11 +107,19 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") }, ); const setStreamError = (error: unknown) => - SubscriptionRef.update(state, (current) => ({ - ...current, - status: shellStatusForSnapshot(current.snapshot), - error: Option.some(formatShellError(error)), - })); + Effect.logWarning("Could not synchronize the environment shell.").pipe( + Effect.annotateLogs({ + environmentId, + ...safeErrorLogAttributes(error), + }), + Effect.andThen( + SubscriptionRef.update(state, (current) => ({ + ...current, + status: shellStatusForSnapshot(current.snapshot), + error: Option.some(SHELL_SYNCHRONIZATION_ERROR_MESSAGE), + })), + ), + ); const applyItem = Effect.fn("EnvironmentShellState.applyItem")(function* ( item: OrchestrationShellStreamItem,