Enable useUnknownInCatchVariables in TypeScript (#68996)

* Enable `useUnknownInCatchVariables`

* Add `isErrnoException`

* Switch from unsafe `error.message` access to `getErrorMessage`/`ensureError`

* Make `serializeError` idempotent

* `TypeError` -> `unknown`

* Simplify `isErrnoException`, require `code`

* Create `isPermissionDeniedError` utility

* Make `isPermissionDeniedError` inline helper
This commit is contained in:
Grzegorz Zdunek
2026-07-31 09:01:36 +00:00
committed by GitHub
parent d1381cab23
commit 3d82a6efe4
59 changed files with 347 additions and 122 deletions
+1
View File
@@ -15,6 +15,7 @@
"strict": false, "strict": false,
"strictBindCallApply": true, "strictBindCallApply": true,
"noImplicitThis": true, "noImplicitThis": true,
"useUnknownInCatchVariables": true,
"noEmitHelpers": true, "noEmitHelpers": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"skipLibCheck": true, "skipLibCheck": true,
@@ -310,10 +310,7 @@ function makeReviewStateOptions(
// Connect) means the reviewer can't see the eligible Access Lists, which // Connect) means the reviewer can't see the eligible Access Lists, which
// is distinct from there being none. Detect it by status code rather than // is distinct from there being none. Detect it by status code rather than
// message text so it survives across versions; surface other errors as-is. // message text so it survives across versions; surface other errors as-is.
const err = fetchSuggestedAccessListsAttempt.error; msg = isPermissionDeniedError(fetchSuggestedAccessListsAttempt.error)
const isPermissionError =
err?.response?.status === 403 || err?.code === 'PERMISSION_DENIED';
msg = isPermissionError
? "You don't have permission to view the Access Lists eligible for long-term approval of this request. You can still reject it." ? "You don't have permission to view the Access Lists eligible for long-term approval of this request. You can still reject it."
: fetchSuggestedAccessListsAttempt.statusText; : fetchSuggestedAccessListsAttempt.statusText;
} else if (request.resources.length === 0) { } else if (request.resources.length === 0) {
@@ -395,3 +392,26 @@ const HorizontalLine = styled.div<{ height?: number }>`
function makeTraitLabel(traitKey: string, traitVals: string[]) { function makeTraitLabel(traitKey: string, traitVals: string[]) {
return `${traitKey}: ${traitVals.sort().join(', ')}`; return `${traitKey}: ${traitVals.sort().join(', ')}`;
} }
/**
* Checks whether an error represents a permission denial from either the Web
* API or a gRPC service.
*
* TODO(gzdunek): Consider passing a permission-error predicate so that Web UI and Connect
* can identify their own native error type.
*/
function isPermissionDeniedError(error: unknown): boolean {
if (typeof error !== 'object' || error === null) {
return false;
}
if ('code' in error && error.code === 'PERMISSION_DENIED') {
return true;
}
return (
'response' in error &&
typeof error.response === 'object' &&
error.response !== null &&
'status' in error.response &&
error.response.status === 403
);
}
@@ -238,12 +238,22 @@ test('disables long-term approval and explains why when no Access List is sugges
// A permission failure (the reviewer can't read the eligible Access Lists) is a // A permission failure (the reviewer can't read the eligible Access Lists) is a
// distinct state from there being none, and gets its own message. // distinct state from there being none, and gets its own message.
test('shows a permission-specific message when the reviewer cannot view eligible Access Lists', async () => { test.each([
[
'HTTP 403',
Object.assign(
new Error('access denied to perform action "read" on access list'),
{ response: { status: 403 } }
),
],
[
'gRPC PERMISSION_DENIED',
Object.assign(new Error('permission denied'), {
code: 'PERMISSION_DENIED',
}),
],
])('shows a permission-specific message for %s', async (_, permissionError) => {
const user = userEvent.setup(); const user = userEvent.setup();
const permissionError = Object.assign(
new Error('access denied to perform action "read" on access list'),
{ response: { status: 403 } }
);
render( render(
<RequestView <RequestView
{...props} {...props}
@@ -30,7 +30,7 @@ import { Logger } from 'design/logger';
import type { ToastNotificationItem } from 'shared/components/ToastNotification'; import type { ToastNotificationItem } from 'shared/components/ToastNotification';
import { Attempt } from 'shared/hooks/useAsync'; import { Attempt } from 'shared/hooks/useAsync';
import { ClientScreenSpec, ClipboardData, TdpClient } from 'shared/libs/tdp'; import { ClientScreenSpec, ClipboardData, TdpClient } from 'shared/libs/tdp';
import { isAbortError } from 'shared/utils/error'; import { getErrorMessage, isAbortError } from 'shared/utils/error';
declare global { declare global {
interface Window { interface Window {
@@ -206,7 +206,7 @@ export default function useDesktopSession(
severity: 'warn', severity: 'warn',
content: { content: {
title: 'Could not share a directory', title: 'Could not share a directory',
description: e.message, description: getErrorMessage(e),
}, },
}); });
} }
@@ -222,7 +222,7 @@ export default function useDesktopSession(
severity: 'warn', severity: 'warn',
content: { content: {
title: 'Failed to unmount the shared directory', title: 'Failed to unmount the shared directory',
description: e.message, description: getErrorMessage(e),
}, },
}); });
} }
@@ -22,9 +22,7 @@ import { FileTransferListeners } from './FileTransferStateless/types';
export interface FileTransferEventsEmitter extends FileTransferListeners { export interface FileTransferEventsEmitter extends FileTransferListeners {
emitProgress(progress: number): void; emitProgress(progress: number): void;
emitError(error: unknown): void;
emitError(error: Error): void;
emitComplete(): void; emitComplete(): void;
} }
+5 -3
View File
@@ -18,6 +18,8 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { getErrorMessage } from 'shared/utils/error';
/** /**
* @deprecated Use TanStack Query (useQuery/useMutation) instead. See RFD 197. * @deprecated Use TanStack Query (useQuery/useMutation) instead. See RFD 197.
* *
@@ -209,7 +211,7 @@ export type Attempt<T> =
status: 'error'; status: 'error';
data: null; data: null;
statusText: string; statusText: string;
error: any; error: unknown;
}; };
export function hasFinished<T>(attempt: Attempt<T>): boolean { export function hasFinished<T>(attempt: Attempt<T>): boolean {
@@ -240,12 +242,12 @@ export function makeProcessingAttempt<T>(): Attempt<T> {
}; };
} }
export function makeErrorAttempt<T>(error: Error): Attempt<T> { export function makeErrorAttempt<T>(error: unknown): Attempt<T> {
return { return {
data: null, data: null,
status: 'error', status: 'error',
error: error, error: error,
statusText: error.message, statusText: getErrorMessage(error),
}; };
} }
+7 -2
View File
@@ -19,6 +19,7 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import Logger from 'shared/libs/logger'; import Logger from 'shared/libs/logger';
import { getErrorMessage } from 'shared/utils/error';
const logger = Logger.create('shared/hooks/useAttempt'); const logger = Logger.create('shared/hooks/useAttempt');
@@ -56,9 +57,13 @@ function makeActions(setState) {
setState({ ...defaultState }); setState({ ...defaultState });
} }
function error(err: Error) { function error(err: unknown) {
logger.error('attempt', err); logger.error('attempt', err);
setState({ ...defaultState, isFailed: true, message: err.message }); setState({
...defaultState,
isFailed: true,
message: getErrorMessage(err),
});
} }
function run(fn: Callback) { function run(fn: Callback) {
+3 -2
View File
@@ -19,6 +19,7 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import Logger from 'shared/libs/logger'; import Logger from 'shared/libs/logger';
import { getErrorMessage } from 'shared/utils/error';
const logger = Logger.create('shared/hooks/useAttempt'); const logger = Logger.create('shared/hooks/useAttempt');
@@ -33,9 +34,9 @@ export default function useAttemptNext(status = '' as Attempt['status']) {
statusText: '', statusText: '',
})); }));
const handleError = useCallback((err: Error) => { const handleError = useCallback((err: unknown) => {
logger.error('attempt', err); logger.error('attempt', err);
setAttempt({ status: 'failed', statusText: err.message }); setAttempt({ status: 'failed', statusText: getErrorMessage(err) });
}, []); }, []);
const run = useCallback((fn: Callback) => { const run = useCallback((fn: Callback) => {
@@ -26,6 +26,7 @@ import {
import { Attempt } from 'shared/hooks/useAttemptNext'; import { Attempt } from 'shared/hooks/useAttemptNext';
import { isAbortError } from 'shared/utils/abortError'; import { isAbortError } from 'shared/utils/abortError';
import { getErrorMessage } from 'shared/utils/error';
// eslint-disable-next-line no-restricted-imports -- FIXME // eslint-disable-next-line no-restricted-imports -- FIXME
import { ResourcesResponse } from 'teleport/services/agents'; import { ResourcesResponse } from 'teleport/services/agents';
@@ -147,7 +148,11 @@ export function useKeyBasedPagination<T>({
} }
setState({ setState({
...stateRef.current, ...stateRef.current,
attempt: { status: 'failed', statusText: err.message, statusCode }, attempt: {
status: 'failed',
statusText: getErrorMessage(err),
statusCode,
},
}); });
} }
}, },
+3 -3
View File
@@ -26,7 +26,7 @@ import init, {
} from 'shared/libs/ironrdp/pkg/ironrdp'; } from 'shared/libs/ironrdp/pkg/ironrdp';
// Inlines the wasm module as a static asset bundled with our app. // Inlines the wasm module as a static asset bundled with our app.
import wasmUrl from 'shared/libs/ironrdp/pkg/ironrdp_bg.wasm?inline'; import wasmUrl from 'shared/libs/ironrdp/pkg/ironrdp_bg.wasm?inline';
import { ensureError, isAbortError } from 'shared/utils/error'; import { ensureError, getErrorMessage, isAbortError } from 'shared/utils/error';
import { import {
Alert, Alert,
@@ -737,7 +737,7 @@ export class TdpClient extends EventEmitter<EventMap> {
path: req.path, path: req.path,
}, },
}); });
this.handleWarning(e.message, TdpClientEvent.CLIENT_WARNING); this.handleWarning(getErrorMessage(e), TdpClientEvent.CLIENT_WARNING);
} }
} }
@@ -762,7 +762,7 @@ export class TdpClient extends EventEmitter<EventMap> {
directoryId: req.directoryId, directoryId: req.directoryId,
errCode: SharedDirectoryErrCode.Failed, errCode: SharedDirectoryErrCode.Failed,
}); });
this.handleWarning(e.message, TdpClientEvent.CLIENT_WARNING); this.handleWarning(getErrorMessage(e), TdpClientEvent.CLIENT_WARNING);
} }
} }
+76 -1
View File
@@ -16,7 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { ensureError, isAbortError } from './error'; import { ensureError, isAbortError, isErrnoException } from './error';
class CustomErrorClass extends Error { class CustomErrorClass extends Error {
constructor(message: string) { constructor(message: string) {
@@ -115,6 +115,81 @@ describe('isAbortError', () => {
}); });
}); });
describe('isErrnoException', () => {
const cases: {
name: string;
input: unknown;
code: string;
expected: boolean;
}[] = [
{
name: 'Error with a matching code',
input: Object.assign(new Error('failed'), { code: 'ENOENT' }),
code: 'ENOENT',
expected: true,
},
{
name: 'AbortError with a matching code',
input: Object.assign(new Error('aborted'), {
name: 'AbortError',
code: 'ABORT_ERR',
}),
code: 'ABORT_ERR',
expected: true,
},
{
name: 'Error with a non-matching code',
input: Object.assign(new Error('failed'), { code: 'EPERM' }),
code: 'ENOENT',
expected: false,
},
{
name: 'Error without a code, expected code',
input: new Error('failed'),
code: 'ENOENT',
expected: false,
},
{
name: 'serialized error with a code',
input: {
name: 'Error',
message: 'failed',
code: 'ENOENT',
},
code: 'ENOENT',
expected: true,
},
{
name: 'object with a non-string code',
input: { code: 123 },
code: 'ENOENT',
expected: false,
},
{
name: 'string',
input: 'ENOENT',
code: 'ENOENT',
expected: false,
},
{
name: 'null',
input: null,
code: 'ENOENT',
expected: false,
},
{
name: 'undefined',
input: undefined,
code: 'ENOENT',
expected: false,
},
];
test.each(cases)('$name', ({ input, code, expected }) => {
expect(isErrnoException(input, code)).toBe(expected);
});
});
function newDOMAbortError() { function newDOMAbortError() {
return new DOMException('Aborted', 'AbortError'); return new DOMException('Aborted', 'AbortError');
} }
+17
View File
@@ -83,3 +83,20 @@ export class AbortError extends DOMException {
super(message, 'AbortError'); super(message, 'AbortError');
} }
} }
/**
* Checks whether an error has the expected Node.js system error code (errno exception).
* Works with both Error instances and serialized errors.
*
* This uses the conventional `error.code` check rather than attempting to
* identify the error's runtime class. Callers should pass a known system error
* code such as `ENOENT`, `EPERM`, or `ESRCH`.
*/
export function isErrnoException(error: unknown, code: string): boolean {
return (
!!error &&
typeof error === 'object' &&
'code' in error &&
error.code === code
);
}
@@ -22,6 +22,7 @@ import { useLocation, useParams } from 'react-router';
import { Flex, Indicator } from 'design'; import { Flex, Indicator } from 'design';
import { AccessDenied } from 'design/CardError'; import { AccessDenied } from 'design/CardError';
import useAttempt from 'shared/hooks/useAttemptNext'; import useAttempt from 'shared/hooks/useAttemptNext';
import { getErrorMessage } from 'shared/utils/error';
import AuthnDialog from 'teleport/components/AuthnDialog'; import AuthnDialog from 'teleport/components/AuthnDialog';
import { CreateAppSessionParams, UrlLauncherParams } from 'teleport/config'; import { CreateAppSessionParams, UrlLauncherParams } from 'teleport/config';
@@ -207,7 +208,7 @@ export function AppLauncher({
// `fetch` returns `TypeError` when there is a network error. // `fetch` returns `TypeError` when there is a network error.
statusText = `Unable to access "${fqdn}". This may happen if your Teleport Proxy is using an untrusted or self-signed certificate. Please ensure Teleport Proxy service uses a valid certificate or access the application domain directly (https://${fqdn}${port}) and accept the certificate exception from your browser.`; statusText = `Unable to access "${fqdn}". This may happen if your Teleport Proxy is using an untrusted or self-signed certificate. Please ensure Teleport Proxy service uses a valid certificate or access the application domain directly (https://${fqdn}${port}) and accept the certificate exception from your browser.`;
} else if (isRedirectFlow) { } else if (isRedirectFlow) {
statusText = `Error while authenticating a required app: ${err.message}`; statusText = `Error while authenticating a required app: ${getErrorMessage(err)}`;
} else if (err instanceof Error) { } else if (err instanceof Error) {
statusText = err.message; statusText = err.message;
} }
@@ -354,6 +355,6 @@ function getNewAuthExchangeUrl({
return url; return url;
} }
function throwFailedToParseUrlError(err: TypeError) { function throwFailedToParseUrlError(err: unknown) {
throw Error(`Failed to parse URL: ${err.message}`); throw Error(`Failed to parse URL: ${getErrorMessage(err)}`);
} }
@@ -32,6 +32,7 @@ import Link from 'design/Link';
import FieldInput from 'shared/components/FieldInput'; import FieldInput from 'shared/components/FieldInput';
import Select from 'shared/components/Select'; import Select from 'shared/components/Select';
import Validation, { Validator } from 'shared/components/Validation'; import Validation, { Validator } from 'shared/components/Validation';
import { getErrorMessage } from 'shared/utils/error';
import cfg from 'teleport/config'; import cfg from 'teleport/config';
@@ -308,10 +309,16 @@ const EnterpriseHostError = () => {
</Box> </Box>
); );
}; };
const InvalidHostError = ({ rule, error }: { rule: string; error: string }) => { const InvalidHostError = ({
rule,
error,
}: {
rule: string;
error: unknown;
}) => {
return ( return (
<Box> <Box>
Invalid address {rule}: {error} Invalid address {rule}: {getErrorMessage(error)}
</Box> </Box>
); );
}; };
@@ -18,6 +18,7 @@
import { Option } from 'shared/components/Select'; import { Option } from 'shared/components/Select';
import { Rule } from 'shared/components/Validation/rules'; import { Rule } from 'shared/components/Validation/rules';
import { getErrorMessage } from 'shared/utils/error';
import { RefType } from 'teleport/services/bot/types'; import { RefType } from 'teleport/services/bot/types';
@@ -113,6 +114,6 @@ export const requireValidRepository: Rule = value => () => {
return { valid: true }; return { valid: true };
} catch (e) { } catch (e) {
return { valid: false, message: e?.message }; return { valid: false, message: getErrorMessage(e) };
} }
}; };
@@ -22,6 +22,7 @@ import { useParams } from 'react-router';
import { Flex, Indicator } from 'design'; import { Flex, Indicator } from 'design';
import { AccessDenied, BadRequest } from 'design/CardError'; import { AccessDenied, BadRequest } from 'design/CardError';
import useAttempt from 'shared/hooks/useAttemptNext'; import useAttempt from 'shared/hooks/useAttemptNext';
import { getErrorMessage } from 'shared/utils/error';
import AuthnDialog from 'teleport/components/AuthnDialog'; import AuthnDialog from 'teleport/components/AuthnDialog';
import { useMfa, shouldShowMfaPrompt } from 'teleport/lib/useMfa'; import { useMfa, shouldShowMfaPrompt } from 'teleport/lib/useMfa';
@@ -74,7 +75,7 @@ export function BrowserMfa({ onRedirect = redirectTo }: BrowserMfaProps) {
} catch (err) { } catch (err) {
setAttempt({ setAttempt({
status: 'failed', status: 'failed',
statusText: err.message, statusText: getErrorMessage(err),
}); });
} }
} }
@@ -26,6 +26,7 @@ import { IconTooltip } from 'design/Tooltip';
import TextEditor from 'shared/components/TextEditor'; import TextEditor from 'shared/components/TextEditor';
import Validation, { Validator } from 'shared/components/Validation'; import Validation, { Validator } from 'shared/components/Validation';
import { useAsync } from 'shared/hooks/useAsync'; import { useAsync } from 'shared/hooks/useAsync';
import { getErrorMessage } from 'shared/utils/error';
import { TextSelectCopyMulti } from 'teleport/components/TextSelectCopy'; import { TextSelectCopyMulti } from 'teleport/components/TextSelectCopy';
import cfg from 'teleport/config'; import cfg from 'teleport/config';
@@ -79,7 +80,7 @@ export function CreateAppAccess() {
resourceName: app.name, resourceName: app.name,
}); });
} catch (err) { } catch (err) {
emitErrorEvent(err.message); emitErrorEvent(getErrorMessage(err));
throw err; throw err;
} }
}); });
@@ -330,7 +330,7 @@ export function useCreateDatabase() {
setAttempt({ status: '' }); setAttempt({ status: '' });
} }
function handleRequestError(err: Error, preErrMsg = '') { function handleRequestError(err: unknown, preErrMsg = '') {
const message = getErrMessage(err); const message = getErrMessage(err);
setAttempt({ status: 'failed', statusText: `${preErrMsg}${message}` }); setAttempt({ status: 'failed', statusText: `${preErrMsg}${message}` });
emitErrorEvent(`${preErrMsg}${message}`); emitErrorEvent(`${preErrMsg}${message}`);
@@ -38,6 +38,7 @@ import { TextSelectCopyMulti } from 'shared/components/TextSelectCopy';
import Validation, { Validator } from 'shared/components/Validation'; import Validation, { Validator } from 'shared/components/Validation';
import { Rule } from 'shared/components/Validation/rules'; import { Rule } from 'shared/components/Validation/rules';
import { makeEmptyAttempt, useAsync } from 'shared/hooks/useAsync'; import { makeEmptyAttempt, useAsync } from 'shared/hooks/useAsync';
import { getErrorMessage } from 'shared/utils/error';
import { LabelsInput } from 'teleport/components/LabelsInput'; import { LabelsInput } from 'teleport/components/LabelsInput';
import cfg from 'teleport/config'; import cfg from 'teleport/config';
@@ -176,7 +177,7 @@ export function DiscoveryConfigSsm() {
}, },
}); });
} catch (err) { } catch (err) {
emitErrorEvent(err.message); emitErrorEvent(getErrorMessage(err));
throw err; throw err;
} }
} }
@@ -19,6 +19,7 @@
import { useState } from 'react'; import { useState } from 'react';
import useAttempt from 'shared/hooks/useAttemptNext'; import useAttempt from 'shared/hooks/useAttemptNext';
import { getErrorMessage } from 'shared/utils/error';
import { import {
getDatabaseProtocol, getDatabaseProtocol,
@@ -107,7 +108,7 @@ export function useConnectionDiagnostic() {
} }
} catch (err) { } catch (err) {
handleError(err); handleError(err);
emitErrorEvent(err.message); emitErrorEvent(getErrorMessage(err));
} }
return { mfaRequired: false }; return { mfaRequired: false };
@@ -28,6 +28,7 @@ import Dialog, {
} from 'design/Dialog'; } from 'design/Dialog';
import Validation, { Validator } from 'shared/components/Validation'; import Validation, { Validator } from 'shared/components/Validation';
import { Attempt, useAsync } from 'shared/hooks/useAsync'; import { Attempt, useAsync } from 'shared/hooks/useAsync';
import { getErrorMessage } from 'shared/utils/error';
import { CatchError } from 'teleport/components/CatchError'; import { CatchError } from 'teleport/components/CatchError';
import cfg from 'teleport/config'; import cfg from 'teleport/config';
@@ -417,12 +418,16 @@ const AttemptAlert = ({ attempt }: { attempt?: Attempt<unknown> }) => {
}; };
/** Renders an alert if there is an error. */ /** Renders an alert if there is an error. */
const ErrorAlert = ({ error }: { error: Error }) => const ErrorAlert = ({ error }: { error: unknown }) => {
error && ( const details = error instanceof Error ? error.cause?.toString() : '';
<Danger mt={3} dismissible details={error.cause?.toString()}> return (
{error.message} error && (
</Danger> <Danger mt={3} dismissible details={details}>
{getErrorMessage(error)}
</Danger>
)
); );
};
const ShowHide = styled(Flex)<{ hidden: boolean }>` const ShowHide = styled(Flex)<{ hidden: boolean }>`
display: ${props => (props.hidden ? 'none' : '')}; display: ${props => (props.hidden ? 'none' : '')};
@@ -85,7 +85,7 @@ export class TtyPlayer extends Player<TtyEvent> {
this.terminal.loadAddon(imageAddon); this.terminal.loadAddon(imageAddon);
this.addons.push(imageAddon); this.addons.push(imageAddon);
} catch (e) { } catch (e) {
this.logger.error(`Failed to load image addon: ${e.message}`); this.logger.error('Failed to load image addon', e);
} }
let webglAddon: WebglAddon | undefined; let webglAddon: WebglAddon | undefined;
@@ -23,6 +23,7 @@ import styled from 'styled-components';
import { Box, ButtonSecondary, Flex, Menu, MenuItem, Text } from 'design'; import { Box, ButtonSecondary, Flex, Menu, MenuItem, Text } from 'design';
import { ChevronDown } from 'design/Icon'; import { ChevronDown } from 'design/Icon';
import { HoverTooltip } from 'design/Tooltip'; import { HoverTooltip } from 'design/Tooltip';
import { getErrorMessage } from 'shared/utils/error';
import cfg from 'teleport/config'; import cfg from 'teleport/config';
import { Cluster } from 'teleport/services/clusters'; import { Cluster } from 'teleport/services/clusters';
@@ -84,7 +85,7 @@ export function ClusterDropdown({
try { try {
return clusterLoader.fetchClusters(signal); return clusterLoader.fetchClusters(signal);
} catch (err) { } catch (err) {
onError(err.message); onError(getErrorMessage(err));
} }
} }
@@ -115,7 +116,7 @@ export function ClusterDropdown({
const res = await loadClusters(signal.signal); const res = await loadClusters(signal.signal);
setOptions(createOptions(res)); setOptions(createOptions(res));
} catch (err) { } catch (err) {
onError(err.message); onError(getErrorMessage(err));
} }
} }
@@ -19,6 +19,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Attempt, makeEmptyAttempt, useAsync } from 'shared/hooks/useAsync'; import { Attempt, makeEmptyAttempt, useAsync } from 'shared/hooks/useAsync';
import { getErrorMessage } from 'shared/utils/error';
import auth from 'teleport/services/auth'; import auth from 'teleport/services/auth';
import { MfaChallengeScope } from 'teleport/services/auth/auth'; import { MfaChallengeScope } from 'teleport/services/auth/auth';
@@ -145,17 +146,18 @@ type challengeState = {
deviceUsage: DeviceUsage; deviceUsage: DeviceUsage;
}; };
function getReAuthenticationErrorMessage(err: Error): string { function getReAuthenticationErrorMessage(err: unknown): string {
if (err.message.includes('attempt was made to use an object that is not')) { const message = getErrorMessage(err);
if (message.includes('attempt was made to use an object that is not')) {
// Catch a webauthn frontend error that occurs on Firefox and replace it with a more helpful error message. // Catch a webauthn frontend error that occurs on Firefox and replace it with a more helpful error message.
return 'The two-factor device you used is not registered on this account. You must verify using a device that has already been registered.'; return 'The two-factor device you used is not registered on this account. You must verify using a device that has already been registered.';
} }
if (err.message === 'invalid totp token') { if (message === 'invalid totp token') {
// This message relies on the status message produced by the auth server in // This message relies on the status message produced by the auth server in
// lib/auth/Server.checkOTP function. Please keep these in sync. // lib/auth/Server.checkOTP function. Please keep these in sync.
return 'Invalid authenticator code'; return 'Invalid authenticator code';
} }
return err.message; return message;
} }
@@ -132,7 +132,7 @@ export default class TtyTerminal implements TerminalSearcher {
this._imageAddon = new ImageAddon(); this._imageAddon = new ImageAddon();
this.term.loadAddon(this._imageAddon); this.term.loadAddon(this._imageAddon);
} catch (e) { } catch (e) {
logger.error('Failed to load image addon:', e.message); logger.error('Failed to load image addon', e);
} }
try { try {
+2 -1
View File
@@ -24,6 +24,7 @@ import {
makeEmptyAttempt, makeEmptyAttempt,
useAsync, useAsync,
} from 'shared/hooks/useAsync'; } from 'shared/hooks/useAsync';
import { getErrorMessage } from 'shared/utils/error';
import { EventEmitterMfaSender } from 'teleport/lib/EventEmitterMfaSender'; import { EventEmitterMfaSender } from 'teleport/lib/EventEmitterMfaSender';
import { TermEvent } from 'teleport/lib/term/enums'; import { TermEvent } from 'teleport/lib/term/enums';
@@ -163,7 +164,7 @@ export function useMfa(props?: MfaProps): MfaState {
setMfaAttempt({ setMfaAttempt({
data: null, data: null,
status: 'error', status: 'error',
statusText: err.message, statusText: getErrorMessage(err),
error: err, error: err,
}); });
} }
@@ -17,6 +17,8 @@
*/ */
import 'whatwg-fetch'; import 'whatwg-fetch';
import { getErrorMessage } from 'shared/utils/error';
import auth, { MfaChallengeScope } from 'teleport/services/auth/auth'; import auth, { MfaChallengeScope } from 'teleport/services/auth/auth';
import websession from 'teleport/services/websession'; import websession from 'teleport/services/websession';
@@ -293,7 +295,7 @@ const api = {
} catch (err) { } catch (err) {
// error reading JSON // error reading JSON
const message = response.ok const message = response.ok
? err.message ? getErrorMessage(err)
: `${response.status} - ${response.url}`; : `${response.status} - ${response.url}`;
throw new ApiError({ message, response, opts: { cause: err } }); throw new ApiError({ message, response, opts: { cause: err } });
} }
@@ -455,8 +457,8 @@ export function getHostName() {
return location.hostname + (location.port ? ':' + location.port : ''); return location.hostname + (location.port ? ':' + location.port : '');
} }
export function isAdminActionRequiresMfaError(err: Error) { export function isAdminActionRequiresMfaError(err: unknown) {
return err.message.includes( return getErrorMessage(err).includes(
'admin-level API request requires MFA verification' 'admin-level API request requires MFA verification'
); );
} }
@@ -20,6 +20,7 @@ import { MutationFunction } from '@tanstack/react-query';
import cfg from 'teleport/config'; import cfg from 'teleport/config';
import api from 'teleport/services/api'; import api from 'teleport/services/api';
import { ApiError } from 'teleport/services/api/parseError';
import { import {
canUseV1Edit, canUseV1Edit,
canUseV2Edit, canUseV2Edit,
@@ -67,7 +68,7 @@ export async function getBot(
.then(makeBot); .then(makeBot);
} catch (err) { } catch (err) {
// capture the not found error response and return null instead of throwing // capture the not found error response and return null instead of throwing
if (err?.response?.status === 404) { if (err instanceof ApiError && err.response.status === 404) {
return null; return null;
} }
throw err; throw err;
@@ -16,7 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { getErrMessage } from 'shared/utils/errorType'; import { getErrorMessage } from 'shared/utils/error';
import { App } from 'teleport/services/apps/types'; import { App } from 'teleport/services/apps/types';
import { import {
@@ -124,7 +124,7 @@ export const withUnsupportedOktaPluginUpdateErrorConversion = (
err: unknown err: unknown
) => { ) => {
if (err instanceof ApiError && err.response.status === 404) { if (err instanceof ApiError && err.response.status === 404) {
const msg = getErrMessage(err); const msg = getErrorMessage(err);
throw new Error( throw new Error(
`Could not update Okta plugin: ${msg}. Your proxy may be behind the minimum required version (v17.3.0) to support Okta plugin updates with this web client.` `Could not update Okta plugin: ${msg}. Your proxy may be behind the minimum required version (v17.3.0) to support Okta plugin updates with this web client.`
); );
@@ -136,7 +136,7 @@ export const withUnsupportedOktaPluginCreateErrorConversion = (
err: unknown err: unknown
) => { ) => {
if (err instanceof ApiError) { if (err instanceof ApiError) {
const msg = getErrMessage(err); const msg = getErrorMessage(err);
if (msg.match(/missing okta (?:organization url|api token)/gi)) { if (msg.match(/missing okta (?:organization url|api token)/gi)) {
throw new Error( throw new Error(
`Could not create Okta plugin: ${msg}. Your proxy may be behind the minimum required version (v17.3.0) to support Okta plugin creation with this web client.` `Could not create Okta plugin: ${msg}. Your proxy may be behind the minimum required version (v17.3.0) to support Okta plugin creation with this web client.`
@@ -147,7 +147,7 @@ export const withUnsupportedOktaPluginCreateErrorConversion = (
}; };
type Base = { type Base = {
err: Error; err: unknown;
}; };
type CreateJoinToken = Base & { type CreateJoinToken = Base & {
@@ -212,7 +212,10 @@ export function useV1Fallback() {
async function tryV1Fallback(props: CreateJoinToken): Promise<JoinToken>; async function tryV1Fallback(props: CreateJoinToken): Promise<JoinToken>;
async function tryV1Fallback(props: FallbackProps) { async function tryV1Fallback(props: FallbackProps) {
if (!props.err.message.includes(ProxyRequiresUpgrade) || hasLabels(props)) { if (
!getErrorMessage(props.err).includes(ProxyRequiresUpgrade) ||
hasLabels(props)
) {
throw props.err; throw props.err;
} }
@@ -25,6 +25,8 @@ import { tmpdir } from 'node:os';
import path from 'node:path'; import path from 'node:path';
import process from 'node:process'; import process from 'node:process';
import { isErrnoException } from 'shared/utils/error';
const stdio = 'pipe'; // Change to 'inherit' for easier debugging. const stdio = 'pipe'; // Change to 'inherit' for easier debugging.
let logsDir: string; let logsDir: string;
@@ -197,7 +199,7 @@ const isRunning = (pid: number) => {
try { try {
return process.kill(pid, 0); return process.kill(pid, 0);
} catch (error) { } catch (error) {
if (error.code === 'ESRCH') { if (isErrnoException(error, 'ESRCH')) {
return false; return false;
} }
+6 -4
View File
@@ -30,7 +30,7 @@ export type DeepLinkParseResult =
// ergonomic. Unfortunately, `if (!result.ok)` doesn't narrow down the type properly with // ergonomic. Unfortunately, `if (!result.ok)` doesn't narrow down the type properly with
// strictNullChecks off. https://github.com/microsoft/TypeScript/issues/10564 // strictNullChecks off. https://github.com/microsoft/TypeScript/issues/10564
| DeepLinkParseResultSuccess | DeepLinkParseResultSuccess
| ParseError<'malformed-url', { error: TypeError }> | ParseError<'malformed-url', { error: unknown }>
| ParseError<'unknown-protocol', { protocol: string }> | ParseError<'unknown-protocol', { protocol: string }>
| ParseError<'unsupported-url'>; | ParseError<'unsupported-url'>;
@@ -69,9 +69,11 @@ export function parseDeepLink(rawUrl: string): DeepLinkParseResult {
try { try {
parsedURL = new URL(rawUrl); parsedURL = new URL(rawUrl);
} catch (error) { } catch (error) {
// `error instanceof TypeError` doesn't work in tests. The URL constructor shouldn't throw other return {
// errors anyway. status: 'error',
return { status: 'error', reason: 'malformed-url', error }; reason: 'malformed-url',
error,
};
} }
if (parsedURL.protocol !== `${CUSTOM_PROTOCOL}:`) { if (parsedURL.protocol !== `${CUSTOM_PROTOCOL}:`) {
+7 -3
View File
@@ -23,7 +23,11 @@ import path from 'node:path';
import { app, dialog, nativeTheme } from 'electron'; import { app, dialog, nativeTheme } from 'electron';
import { CUSTOM_PROTOCOL } from 'shared/deepLinks'; import { CUSTOM_PROTOCOL } from 'shared/deepLinks';
import { ensureError } from 'shared/utils/error'; import {
ensureError,
getErrorMessage,
isErrnoException,
} from 'shared/utils/error';
import { parseDeepLink } from 'teleterm/deepLinks'; import { parseDeepLink } from 'teleterm/deepLinks';
import Logger from 'teleterm/logger'; import Logger from 'teleterm/logger';
@@ -366,7 +370,7 @@ function launchDeepLink(
break; break;
} }
case 'malformed-url': { case 'malformed-url': {
reason = `malformed URL (${result.error.message})`; reason = `malformed URL (${getErrorMessage(result.error)})`;
break; break;
} }
default: { default: {
@@ -429,7 +433,7 @@ async function migrateOldTshHomeOnce(
try { try {
await fs.stat(oldTshHome); await fs.stat(oldTshHome);
} catch (err) { } catch (err) {
if (err.code === 'ENOENT') { if (isErrnoException(err, 'ENOENT')) {
logger.info( logger.info(
'Old tsh directory does not exist, marking migration as processed' 'Old tsh directory does not exist, marking migration as processed'
); );
@@ -26,6 +26,7 @@ import { createUnzip } from 'node:zlib';
import { extract } from 'tar-fs'; import { extract } from 'tar-fs';
import { isErrnoException } from 'shared/utils/error';
import { compareSemVers } from 'shared/utils/semVer'; import { compareSemVers } from 'shared/utils/semVer';
import Logger from 'teleterm/logger'; import Logger from 'teleterm/logger';
@@ -149,7 +150,7 @@ async function isCorrectAgentVersionAlreadyDownloaded(
return agentVersion.stdout.trim() === neededVersion; return agentVersion.stdout.trim() === neededVersion;
} catch (e) { } catch (e) {
// When the agent is being downloaded for the first time, the binary does not yet exist. // When the agent is being downloaded for the first time, the binary does not yet exist.
if (e.code !== 'ENOENT') { if (!isErrnoException(e, 'ENOENT')) {
throw e; throw e;
} }
return false; return false;
@@ -20,7 +20,6 @@ import {
Cluster, Cluster,
LoggedInUser, LoggedInUser,
} from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb'; } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb';
import { ensureError } from 'shared/utils/error';
import Logger from 'teleterm/logger'; import Logger from 'teleterm/logger';
import type { IAwaitableSender } from 'teleterm/mainProcess/awaitableSender'; import type { IAwaitableSender } from 'teleterm/mainProcess/awaitableSender';
@@ -381,7 +380,7 @@ export class ClusterLifecycleManager {
); );
const serialized: ProfileWatcherError = { const serialized: ProfileWatcherError = {
reason: watcherError.reason, reason: watcherError.reason,
error: serializeError(ensureError(watcherError.error)), error: serializeError(watcherError.error),
}; };
this.windowsManager this.windowsManager
.getWindow() .getWindow()
@@ -22,6 +22,7 @@ import path from 'node:path';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import * as connectMyComputer from 'shared/connectMyComputer'; import * as connectMyComputer from 'shared/connectMyComputer';
import { isErrnoException } from 'shared/utils/error';
import { RuntimeSettings } from 'teleterm/mainProcess/types'; import { RuntimeSettings } from 'teleterm/mainProcess/types';
import { RootClusterUri, routing } from 'teleterm/ui/uri'; import { RootClusterUri, routing } from 'teleterm/ui/uri';
@@ -127,7 +128,7 @@ export async function isAgentConfigFileCreated(
await fs.access(configFile); await fs.access(configFile);
return true; return true;
} catch (e) { } catch (e) {
if (e.code === 'ENOENT') { if (isErrnoException(e, 'ENOENT')) {
return false; return false;
} }
throw e; throw e;
@@ -64,3 +64,17 @@ test('serializes and deserializes RPC error', () => {
'is-resolvable-with-relogin': ['1'], 'is-resolvable-with-relogin': ['1'],
}); });
}); });
test('serializing an already serialized RPC error preserves custom fields', () => {
const err = new RpcError('Session expired', 'UNAUTHENTICATED', {
'is-resolvable-with-relogin': ['1'],
});
const serialized = serializeError(err);
const serializedAgain = serializeError(structuredClone(serialized));
expect(serializedAgain['code']).toBe('UNAUTHENTICATED');
expect(serializedAgain['meta']).toEqual({
'is-resolvable-with-relogin': ['1'],
});
});
@@ -16,16 +16,34 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { ensureError } from 'shared/utils/error';
export type SerializedError = { export type SerializedError = {
name: string; name: string;
message: string; message: string;
stack?: string; stack?: string;
cause?: unknown; cause?: unknown;
toStringResult?: string; toStringResult?: string;
$serializedError?: true;
}; };
/** Serializes an Error into a plain object for transport through Electron IPC. */ function isSerialized(error: unknown): error is SerializedError {
export function serializeError(error: Error): SerializedError { return (
!!error &&
typeof error === 'object' &&
error['$serializedError'] === true &&
typeof error['name'] === 'string' &&
typeof error['message'] === 'string'
);
}
/** Serializes an error into a plain object for transport through Electron IPC. */
export function serializeError(error: unknown): SerializedError {
if (isSerialized(error)) {
return error;
}
const errorInstance = ensureError(error);
const { const {
name, name,
cause, cause,
@@ -34,7 +52,7 @@ export function serializeError(error: Error): SerializedError {
// functions must be skipped, otherwise structuredClone will fail to clone the object // functions must be skipped, otherwise structuredClone will fail to clone the object
toString, toString,
...enumerableFields ...enumerableFields
} = error; } = errorInstance;
return { return {
name, name,
message, message,
@@ -42,14 +60,23 @@ export function serializeError(error: Error): SerializedError {
stack, stack,
// Calling the destructured function directly could result in the following error: // Calling the destructured function directly could result in the following error:
// Method Error.prototype.toString called on incompatible receiver undefined // Method Error.prototype.toString called on incompatible receiver undefined
toStringResult: error.toString?.(), toStringResult: errorInstance.toString?.(),
...enumerableFields, ...enumerableFields,
$serializedError: true,
}; };
} }
/** Deserializes a plain object back into an Error instance. */ /** Deserializes a plain object back into an Error instance. */
export function deserializeError(serialized: SerializedError): Error { export function deserializeError(serialized: SerializedError): Error {
const { name, cause, stack, message, toStringResult, ...rest } = serialized; const {
name,
cause,
stack,
message,
toStringResult,
$serializedError,
...rest
} = serialized;
const error = new Error(message); const error = new Error(message);
error.name = name; error.name = name;
error.cause = cause; error.cause = cause;
@@ -19,6 +19,8 @@
import crypto from 'crypto'; import crypto from 'crypto';
import fs from 'fs'; import fs from 'fs';
import { getErrorMessage } from 'shared/utils/error';
const UUID_V4_REGEX = const UUID_V4_REGEX =
/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i; /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i;
@@ -46,7 +48,7 @@ function writeInstallationId(filePath: string): string {
fs.writeFileSync(filePath, newId); fs.writeFileSync(filePath, newId);
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Could not write installation_id to ${filePath}, ${error.message}`, `Could not write installation_id to ${filePath}, ${getErrorMessage(error)}`,
{ cause: error } { cause: error }
); );
} }
@@ -18,8 +18,6 @@
import { ipcRenderer } from 'electron'; import { ipcRenderer } from 'electron';
import { ensureError } from 'shared/utils/error';
import Logger from 'teleterm/logger'; import Logger from 'teleterm/logger';
import type { Message, MessageAck } from 'teleterm/mainProcess/awaitableSender'; import type { Message, MessageAck } from 'teleterm/mainProcess/awaitableSender';
import { CreateAgentConfigFileArgs } from 'teleterm/mainProcess/createAgentConfigFile'; import { CreateAgentConfigFileArgs } from 'teleterm/mainProcess/createAgentConfigFile';
@@ -325,7 +323,7 @@ function startAwaitableSenderListener<T>(
try { try {
await listener(msg.payload as T); await listener(msg.payload as T);
} catch (e) { } catch (e) {
ack.error = serializeError(ensureError(e)); ack.error = serializeError(e);
} }
try { try {
@@ -23,6 +23,7 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb'; import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb';
import { isErrnoException } from 'shared/utils/error';
import { wait } from 'shared/utils/wait'; import { wait } from 'shared/utils/wait';
import Logger, { NullService } from 'teleterm/logger'; import Logger, { NullService } from 'teleterm/logger';
@@ -65,7 +66,7 @@ async function mockTshClient(tshDir: string, initial: { clusters: Cluster[] }) {
try { try {
paths = await fs.readdir(tshDir); paths = await fs.readdir(tshDir);
} catch (err) { } catch (err) {
if (err.code === 'ENOENT') { if (isErrnoException(err, 'ENOENT')) {
throw { throw {
name: 'RpcError', name: 'RpcError',
code: 'NOT_FOUND', code: 'NOT_FOUND',
@@ -88,7 +89,7 @@ async function mockTshClient(tshDir: string, initial: { clusters: Cluster[] }) {
// The file with the cluster disappeared between fs.readdir above and fs.readFile. // The file with the cluster disappeared between fs.readdir above and fs.readFile.
// This is possible in tests where we call `void tshClientMock.removeCluster` without // This is possible in tests where we call `void tshClientMock.removeCluster` without
// awaiting. // awaiting.
if (err.code === 'ENOENT') { if (isErrnoException(err, 'ENOENT')) {
return null; return null;
} }
throw err; throw err;
@@ -20,6 +20,7 @@ import { watch, type WatchEventType } from 'node:fs';
import { access } from 'node:fs/promises'; import { access } from 'node:fs/promises';
import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb'; import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb';
import { isErrnoException } from 'shared/utils/error';
import { debounce } from 'shared/utils/highbar'; import { debounce } from 'shared/utils/highbar';
import { wait } from 'shared/utils/wait'; import { wait } from 'shared/utils/wait';
@@ -129,7 +130,7 @@ export async function* watchProfiles({
if ( if (
isTshdRpcError(error, 'NOT_FOUND') || isTshdRpcError(error, 'NOT_FOUND') ||
error instanceof FileSystemEventsOverflowError || error instanceof FileSystemEventsOverflowError ||
error?.code === 'EPERM' isErrnoException(error, 'EPERM')
) { ) {
const ok = await pathExists(tshDirectory); const ok = await pathExists(tshDirectory);
if (!ok) { if (!ok) {
@@ -161,7 +162,7 @@ async function pathExists(dirPath: string): Promise<boolean> {
await access(dirPath); await access(dirPath);
return true; return true;
} catch (error) { } catch (error) {
if (error.code === 'ENOENT') { if (isErrnoException(error, 'ENOENT')) {
return false; return false;
} }
throw error; throw error;
@@ -246,7 +246,8 @@ export class WindowsManager {
await this.whenFrontendAppIsReady(); await this.whenFrontendAppIsReady();
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`Could not send the deep link to the frontend app: ${error.message}` 'Could not send the deep link to the frontend app',
error
); );
return; return;
} }
@@ -24,7 +24,7 @@ import { NsisUpdater } from 'electron-updater';
import { DownloadUpdateOptions } from 'electron-updater/out/AppUpdater'; import { DownloadUpdateOptions } from 'electron-updater/out/AppUpdater';
import { InstallOptions } from 'electron-updater/out/BaseUpdater'; import { InstallOptions } from 'electron-updater/out/BaseUpdater';
import { getErrorMessage } from 'shared/utils/error'; import { ensureError, getErrorMessage } from 'shared/utils/error';
import { import {
TSH_AUTOUPDATE_ENV_VAR, TSH_AUTOUPDATE_ENV_VAR,
@@ -100,7 +100,7 @@ export class NsisDualModeUpdater extends NsisUpdater {
[TSH_AUTOUPDATE_ENV_VAR]: TSH_AUTOUPDATE_OFF, [TSH_AUTOUPDATE_ENV_VAR]: TSH_AUTOUPDATE_OFF,
}); });
} catch (error) { } catch (error) {
this.dispatchError(error); this.dispatchError(ensureError(error));
const errorMessage = getErrorMessage(error); const errorMessage = getErrorMessage(error);
if (!errorMessage.includes('failed to ensure service is running')) { if (!errorMessage.includes('failed to ensure service is running')) {
// If not a problem with starting the service, keep the app open and surface the error in the UI. // If not a problem with starting the service, keep the app open and surface the error in the UI.
@@ -33,7 +33,7 @@ const logger = new Logger('ConfigService');
export type FileLoadingError = { export type FileLoadingError = {
source: 'file-loading'; source: 'file-loading';
error: Error; error: unknown;
}; };
export type ValidationError = { export type ValidationError = {
@@ -21,6 +21,7 @@ import fs from 'node:fs';
import fsAsync from 'node:fs/promises'; import fsAsync from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { isErrnoException } from 'shared/utils/error';
import { debounce } from 'shared/utils/highbar'; import { debounce } from 'shared/utils/highbar';
import Logger from 'teleterm/logger'; import Logger from 'teleterm/logger';
@@ -52,7 +53,7 @@ export interface FileStorage {
getFileName(): string; getFileName(): string;
/** Returns the error that could occur while reading and parsing the file. */ /** Returns the error that could occur while reading and parsing the file. */
getFileLoadingError(): Error | undefined; getFileLoadingError(): unknown;
} }
/** /**
@@ -80,7 +81,7 @@ export function createFileStorage(opts: {
const { filePath } = opts; const { filePath } = opts;
let state: any, error: Error | undefined; let state: any, error: unknown;
try { try {
state = loadStateSync(filePath); state = loadStateSync(filePath);
} catch (e) { } catch (e) {
@@ -127,7 +128,7 @@ export function createFileStorage(opts: {
return path.basename(opts.filePath); return path.basename(opts.filePath);
} }
function getFileLoadingError(): Error | undefined { function getFileLoadingError(): unknown {
return error; return error;
} }
@@ -161,7 +162,7 @@ function readOrCreateFileSync(filePath: string): string {
try { try {
return fs.readFileSync(filePath, { encoding: 'utf-8' }); return fs.readFileSync(filePath, { encoding: 'utf-8' });
} catch (error) { } catch (error) {
if (error?.code === 'ENOENT') { if (isErrnoException(error, 'ENOENT')) {
fs.writeFileSync(filePath, defaultValue); fs.writeFileSync(filePath, defaultValue);
return defaultValue; return defaultValue;
} }
@@ -20,6 +20,7 @@ import { watch, type Stats } from 'fs';
import { readFile, rename, stat, writeFile } from 'fs/promises'; import { readFile, rename, stat, writeFile } from 'fs/promises';
import path from 'path'; import path from 'path';
import { isErrnoException } from 'shared/utils/error';
import { wait } from 'shared/utils/wait'; import { wait } from 'shared/utils/wait';
import { makeCert } from './makeCert'; import { makeCert } from './makeCert';
@@ -68,7 +69,7 @@ export async function readGrpcCert(
try { try {
stats = await stat(fullPath); stats = await stat(fullPath);
} catch (error) { } catch (error) {
if (error?.code === 'ENOENT') { if (isErrnoException(error, 'ENOENT')) {
return false; return false;
} }
throw error; throw error;
@@ -28,8 +28,6 @@ import {
UnaryCall, UnaryCall,
} from '@protobuf-ts/runtime-rpc'; } from '@protobuf-ts/runtime-rpc';
import { ensureError } from 'shared/utils/error';
import { import {
serializeError, serializeError,
type SerializedError, type SerializedError,
@@ -314,7 +312,7 @@ export function isRpcErrorReloginResolvable(error: unknown): boolean {
} }
function cloneError(error: unknown): SerializedError { function cloneError(error: unknown): SerializedError {
return serializeError(ensureError(error)); return serializeError(error);
} }
function cloneRequests<O extends object>( function cloneRequests<O extends object>(
@@ -24,6 +24,7 @@ import { promisify } from 'node:util';
import * as nodePTY from 'node-pty'; import * as nodePTY from 'node-pty';
import which from 'which'; import which from 'which';
import { getErrorMessage } from 'shared/utils/error';
import { wait } from 'shared/utils/wait'; import { wait } from 'shared/utils/wait';
import Logger from 'teleterm/logger'; import Logger from 'teleterm/logger';
@@ -257,11 +258,11 @@ export class PtyProcess extends EventEmitter implements IPtyProcess {
} }
} }
private handleStartError(error: Error) { private handleStartError(error: unknown) {
const command = `${this.options.path} ${this.options.args.join(' ')}`; const command = `${this.options.path} ${this.options.args.join(' ')}`;
this.emit( this.emit(
TermEventEnum.StartError, TermEventEnum.StartError,
`Cannot execute ${command}: ${error.message}` `Cannot execute ${command}: ${getErrorMessage(error)}`
); );
} }
@@ -20,6 +20,7 @@ import { useCallback, useEffect, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { Indicator } from 'design'; import { Indicator } from 'design';
import { getErrorMessage } from 'shared/utils/error';
import { useAppContext } from 'teleterm/ui/appContextProvider'; import { useAppContext } from 'teleterm/ui/appContextProvider';
import { useLogger } from 'teleterm/ui/hooks/useLogger'; import { useLogger } from 'teleterm/ui/hooks/useLogger';
@@ -57,10 +58,10 @@ export const AppInitializer = () => {
success: true, success: true,
}); });
} catch (error) { } catch (error) {
logger.error(error?.message); logger.error('Failed to initialize app', error);
setShouldShowUi(true); setShouldShowUi(true);
appContext?.notificationsService.notifyError(error?.message); appContext?.notificationsService.notifyError(getErrorMessage(error));
appContext?.mainProcessClient.signalUserInterfaceReadiness({ appContext?.mainProcessClient.signalUserInterfaceReadiness({
success: false, success: false,
}); });
@@ -22,6 +22,7 @@ import styled from 'styled-components';
import { Alert, Box, ButtonPrimary, Flex, H1, Text } from 'design'; import { Alert, Box, ButtonPrimary, Flex, H1, Text } from 'design';
import * as Alerts from 'design/Alert'; import * as Alerts from 'design/Alert';
import { Attempt, makeEmptyAttempt, useAsync } from 'shared/hooks/useAsync'; import { Attempt, makeEmptyAttempt, useAsync } from 'shared/hooks/useAsync';
import { getErrorMessage } from 'shared/utils/error';
import { wait } from 'shared/utils/wait'; import { wait } from 'shared/utils/wait';
import { import {
@@ -244,7 +245,7 @@ function AgentSetup() {
!isRpcErrorReloginResolvable(error) !isRpcErrorReloginResolvable(error)
) { ) {
throw new Error( throw new Error(
`Cannot set up the role: ${error.message}. Contact your administrator for permissions to manage users and roles.`, `Cannot set up the role: ${getErrorMessage(error)}. Contact your administrator for permissions to manage users and roles.`,
{ cause: error } { cause: error }
); );
} }
@@ -431,7 +432,7 @@ function AgentSetup() {
const { agentBinaryPath } = mainProcessClient.getRuntimeSettings(); const { agentBinaryPath } = mainProcessClient.getRuntimeSettings();
notificationsService.notifyError({ notificationsService.notifyError({
title: 'Could not remove the agent binary', title: 'Could not remove the agent binary',
description: `Please try removing the binary manually to continue. The binary is at ${agentBinaryPath}. The error message was: ${error.message}`, description: `Please try removing the binary manually to continue. The binary is at ${agentBinaryPath}. The error message was: ${getErrorMessage(error)}`,
}); });
return; return;
} }
@@ -36,6 +36,7 @@ import * as icons from 'design/Icon';
import type { IconProps } from 'design/Icon/Icon'; import type { IconProps } from 'design/Icon/Icon';
import Indicator from 'design/Indicator'; import Indicator from 'design/Indicator';
import { MenuIcon } from 'shared/components/MenuAction'; import { MenuIcon } from 'shared/components/MenuAction';
import { getErrorMessage } from 'shared/utils/error';
import type * as tsh from 'teleterm/services/tshd/types'; import type * as tsh from 'teleterm/services/tshd/types';
import { useAppContext } from 'teleterm/ui/appContextProvider'; import { useAppContext } from 'teleterm/ui/appContextProvider';
@@ -112,7 +113,7 @@ export function Status(props: { closeDocument?: () => void }) {
} catch (e) { } catch (e) {
ctx.notificationsService.notifyError({ ctx.notificationsService.notifyError({
title: 'Failed to open agent logs directory', title: 'Failed to open agent logs directory',
description: `${e.message}\n\nNote: the logs directory is created only after the agent process successfully spawns.`, description: `${getErrorMessage(e)}\n\nNote: the logs directory is created only after the agent process successfully spawns.`,
}); });
} }
} }
@@ -22,6 +22,7 @@ import {
DeepURL, DeepURL,
VnetDeepURL, VnetDeepURL,
} from 'shared/deepLinks'; } from 'shared/deepLinks';
import { getErrorMessage } from 'shared/utils/error';
import { DeepLinkParseResult } from 'teleterm/deepLinks'; import { DeepLinkParseResult } from 'teleterm/deepLinks';
import { IAppContext } from 'teleterm/ui/types'; import { IAppContext } from 'teleterm/ui/types';
@@ -59,7 +60,7 @@ export async function launchDeepLink(
break; break;
} }
case 'malformed-url': { case 'malformed-url': {
reason = `The URL of the link appears to be malformed. ${result.error.message}`; reason = `The URL of the link appears to be malformed. ${getErrorMessage(result.error)}`;
break; break;
} }
default: { default: {
@@ -238,14 +238,15 @@ export default class TtyTerminal implements TerminalSearcher {
this.term.onData(data => { this.term.onData(data => {
this.ptyProcess.write(data).catch(error => { this.ptyProcess.write(data).catch(error => {
this.logger.error(`Failed to write to the PTY process: ${error}`); this.logger.error('Failed to write to the PTY process', error);
}); });
}); });
this.term.onResize(size => { this.term.onResize(size => {
this.ptyProcess.resize(size.cols, size.rows).catch(error => { this.ptyProcess.resize(size.cols, size.rows).catch(error => {
this.logger.error( this.logger.error(
`Failed to send resize request to the PTY process: ${error}` 'Failed to send resize request to the PTY process',
error
); );
}); });
}); });
@@ -259,7 +260,7 @@ export default class TtyTerminal implements TerminalSearcher {
// The shared process version of PtyProcess knows whether it was started or not (the status // The shared process version of PtyProcess knows whether it was started or not (the status
// field), so it's a matter of exposing this field through gRPC and reading it here. // field), so it's a matter of exposing this field through gRPC and reading it here.
this.ptyProcess.start(this.term.cols, this.term.rows).catch(error => { this.ptyProcess.start(this.term.cols, this.term.rows).catch(error => {
this.logger.error(`Failed to start the PTY process: ${error}`); this.logger.error('Failed to start the PTY process', error);
}); });
window.addEventListener('resize', this.debouncedResize); window.addEventListener('resize', this.debouncedResize);
@@ -89,7 +89,7 @@ export function useDocumentTerminal(doc: types.DocumentTerminal) {
return () => { return () => {
if (attempt.status === 'success') { if (attempt.status === 'success') {
void attempt.data.ptyProcess.dispose().catch(error => { void attempt.data.ptyProcess.dispose().catch(error => {
logger.error(`Failed to dispose of the PTY process: ${error}`); logger.error('Failed to dispose of the PTY process', error);
}); });
} }
}; };
@@ -36,6 +36,7 @@ import { copyToClipboard } from 'design/utils/copyToClipboard';
import { Timestamp } from 'gen-proto-ts/google/protobuf/timestamp_pb'; import { Timestamp } from 'gen-proto-ts/google/protobuf/timestamp_pb';
import * as diag from 'gen-proto-ts/teleport/lib/vnet/diag/v1/diag_pb'; import * as diag from 'gen-proto-ts/teleport/lib/vnet/diag/v1/diag_pb';
import { CanceledError, useAsync } from 'shared/hooks/useAsync'; import { CanceledError, useAsync } from 'shared/hooks/useAsync';
import { getErrorMessage } from 'shared/utils/error';
import { pluralize } from 'shared/utils/text'; import { pluralize } from 'shared/utils/text';
import { import {
@@ -122,7 +123,7 @@ export function DocumentVnetDiagReport(props: {
previousSaveToFileNotificationIdRef.current = previousSaveToFileNotificationIdRef.current =
notificationsService.notifyError({ notificationsService.notifyError({
title: 'Could not save the report to a file.', title: 'Could not save the report to a file.',
description: error?.message, description: getErrorMessage(error),
}); });
return; return;
} }
@@ -16,6 +16,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { getErrorMessage } from 'shared/utils/error';
import { IAppContext } from 'teleterm/ui/types'; import { IAppContext } from 'teleterm/ui/types';
import { ClusterUri, RootClusterUri, routing } from 'teleterm/ui/uri'; import { ClusterUri, RootClusterUri, routing } from 'teleterm/ui/uri';
@@ -35,7 +37,7 @@ const commands = {
error => { error => {
ctx.notificationsService.notifyError({ ctx.notificationsService.notifyError({
title: 'Could not install tsh in PATH', title: 'Could not install tsh in PATH',
description: `Ran into an error: ${error}`, description: `Ran into an error: ${getErrorMessage(error)}`,
}); });
} }
); );
@@ -57,7 +59,7 @@ const commands = {
error => { error => {
ctx.notificationsService.notifyError({ ctx.notificationsService.notifyError({
title: 'Could not remove tsh from PATH', title: 'Could not remove tsh from PATH',
description: `Ran into an error: ${error}`, description: `Ran into an error: ${getErrorMessage(error)}`,
}); });
} }
); );
@@ -26,7 +26,7 @@ import {
ReviewAccessRequestRequest, ReviewAccessRequestRequest,
} from 'gen-proto-ts/teleport/lib/teleterm/v1/service_pb'; } from 'gen-proto-ts/teleport/lib/teleterm/v1/service_pb';
import { useStore } from 'shared/libs/stores'; import { useStore } from 'shared/libs/stores';
import { AbortError, isAbortError } from 'shared/utils/error'; import { AbortError, getErrorMessage, isAbortError } from 'shared/utils/error';
import type { State as ClustersState } from 'teleterm/mainProcess/clusterStore'; import type { State as ClustersState } from 'teleterm/mainProcess/clusterStore';
import { MainProcessClient } from 'teleterm/mainProcess/types'; import { MainProcessClient } from 'teleterm/mainProcess/types';
@@ -89,7 +89,7 @@ export class ClustersService extends ImmutableStore<ClustersServiceState> {
const notificationId = this.notificationsService.notifyError({ const notificationId = this.notificationsService.notifyError({
title: `Could not synchronize cluster ${clusterName}`, title: `Could not synchronize cluster ${clusterName}`,
description: e.message, description: getErrorMessage(e),
action: { action: {
content: 'Retry', content: 'Retry',
onClick: () => { onClick: () => {
@@ -112,7 +112,7 @@ export class ClustersService extends ImmutableStore<ClustersServiceState> {
const notificationId = this.notificationsService.notifyError({ const notificationId = this.notificationsService.notifyError({
title: `Could not start headless requests watcher for ${clusterName}`, title: `Could not start headless requests watcher for ${clusterName}`,
description: e.message, description: getErrorMessage(e),
action: { action: {
content: 'Retry', content: 'Retry',
onClick: () => { onClick: () => {
@@ -166,7 +166,7 @@ export class ClustersService extends ImmutableStore<ClustersServiceState> {
} }
const notificationId = this.notificationsService.notifyError({ const notificationId = this.notificationsService.notifyError({
title: 'Could not fetch root clusters', title: 'Could not fetch root clusters',
description: error.message, description: getErrorMessage(error),
action: { action: {
content: 'Retry', content: 'Retry',
onClick: () => { onClick: () => {
@@ -193,7 +193,7 @@ export class ClustersService extends ImmutableStore<ClustersServiceState> {
} catch (error) { } catch (error) {
const notificationId = this.notificationsService.notifyError({ const notificationId = this.notificationsService.notifyError({
title: 'Could not synchronize database connections', title: 'Could not synchronize database connections',
description: error.message, description: getErrorMessage(error),
action: { action: {
content: 'Retry', content: 'Retry',
onClick: () => { onClick: () => {
@@ -301,7 +301,7 @@ export class ClustersService extends ImmutableStore<ClustersServiceState> {
const notificationId = this.notificationsService.notifyError({ const notificationId = this.notificationsService.notifyError({
title, title,
description: error.message, description: getErrorMessage(error),
action: { action: {
content: 'Retry', content: 'Retry',
onClick: () => { onClick: () => {
@@ -19,6 +19,7 @@
import { Timestamp } from 'gen-proto-ts/google/protobuf/timestamp_pb'; import { Timestamp } from 'gen-proto-ts/google/protobuf/timestamp_pb';
import { SubmitConnectEventRequest } from 'gen-proto-ts/prehog/v1alpha/connect_pb'; import { SubmitConnectEventRequest } from 'gen-proto-ts/prehog/v1alpha/connect_pb';
import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb'; import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb';
import { getErrorMessage } from 'shared/utils/error';
import Logger from 'teleterm/logger'; import Logger from 'teleterm/logger';
import { RuntimeSettings } from 'teleterm/mainProcess/types'; import { RuntimeSettings } from 'teleterm/mainProcess/types';
@@ -304,9 +305,9 @@ export class UsageService {
} catch (e) { } catch (e) {
this.notificationsService.notifyWarning({ this.notificationsService.notifyWarning({
title: 'Failed to report usage event', title: 'Failed to report usage event',
description: e.message, description: getErrorMessage(e),
}); });
this.logger.warn(`Failed to report usage event`, e.message); this.logger.warn('Failed to report usage event', e);
} }
} }
@@ -54,7 +54,7 @@ export async function retryWithRelogin<T>(
resourceUri: ClusterOrResourceUri, resourceUri: ClusterOrResourceUri,
actionToRetry: () => Promise<T> actionToRetry: () => Promise<T>
): Promise<T> { ): Promise<T> {
let retryableErrorFromActionToRetry: Error; let retryableErrorFromActionToRetry: unknown;
try { try {
return await actionToRetry(); return await actionToRetry();
} catch (error) { } catch (error) {