From dc7dcaf1b10013cdf298035b77e00c253f71b38e Mon Sep 17 00:00:00 2001 From: Bernhard Wittmann Date: Tue, 12 May 2026 10:34:33 +0200 Subject: [PATCH] fix: Show friendly message in computer use cli when connection token is invalid (no-changelog) (#30288) --- packages/@n8n/computer-use/src/cli.ts | 13 +++- .../computer-use/src/gateway-client.test.ts | 64 ++++++++++++++++++- .../@n8n/computer-use/src/gateway-client.ts | 14 ++++ packages/@n8n/computer-use/src/logger.ts | 7 ++ 4 files changed, 95 insertions(+), 3 deletions(-) diff --git a/packages/@n8n/computer-use/src/cli.ts b/packages/@n8n/computer-use/src/cli.ts index 7f87ab77d08..dff16551ede 100644 --- a/packages/@n8n/computer-use/src/cli.ts +++ b/packages/@n8n/computer-use/src/cli.ts @@ -5,13 +5,14 @@ import * as fs from 'node:fs/promises'; import { isOriginAllowed, parseConfig } from './config'; import { cliConfirmResourceAccess, sanitizeForTerminal } from './confirm-resource-cli'; -import { GatewayClient } from './gateway-client'; +import { GatewayAuthError, GatewayClient } from './gateway-client'; import { GatewaySession } from './gateway-session'; import { configure, logger, printBanner, printConnected, + printInvalidToken, printModuleStatus, printToolList, } from './logger'; @@ -223,7 +224,15 @@ async function main( process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); - await client.start(); + try { + await client.start(); + } catch (error) { + if (error instanceof GatewayAuthError) { + printInvalidToken(origin); + process.exit(1); + } + throw error; + } printConnected(url); printToolList(client.tools); diff --git a/packages/@n8n/computer-use/src/gateway-client.test.ts b/packages/@n8n/computer-use/src/gateway-client.test.ts index 59a1f1ccd90..98df169b347 100644 --- a/packages/@n8n/computer-use/src/gateway-client.test.ts +++ b/packages/@n8n/computer-use/src/gateway-client.test.ts @@ -41,7 +41,7 @@ jest.mock('./tools/browser', () => ({ })); import type { GatewayConfig } from './config'; -import { GatewayClient } from './gateway-client'; +import { GatewayAuthError, GatewayClient } from './gateway-client'; import type { GatewaySession } from './gateway-session'; import type { AffectedResource, ConfirmResourceAccess, ToolDefinition } from './tools/types'; import { INSTANCE_RESOURCE_DECISION_KEYS } from './tools/types'; @@ -257,3 +257,65 @@ describe('GatewayClient.checkPermissions', () => { }); }); }); + +describe('GatewayClient.uploadCapabilities', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + function makeMinimalClient(): GatewayClient { + const client = new GatewayClient({ + url: 'http://localhost:5678', + apiKey: 'tok', + config: makeConfig(), + session: makeSession(), + confirmResourceAccess: jest.fn(), + }); + + // Bypass tool discovery — uploadCapabilities only needs definitions to exist. + // @ts-expect-error — accessing private field for testing + client.allDefinitions = []; + // @ts-expect-error — accessing private field for testing + client.activeToolCategories = []; + + return client; + } + + function mockFetchResponse(status: number, body = ''): void { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: status >= 200 && status < 300, + status, + text: jest.fn().mockResolvedValue(body), + json: jest.fn().mockResolvedValue({ data: { ok: true } }), + }); + } + + it('throws GatewayAuthError on 401', async () => { + mockFetchResponse(401, 'invalid token'); + const client = makeMinimalClient(); + + await expect(client['uploadCapabilities']()).rejects.toBeInstanceOf(GatewayAuthError); + }); + + it('throws GatewayAuthError on 403', async () => { + mockFetchResponse(403, 'forbidden'); + const client = makeMinimalClient(); + + await expect(client['uploadCapabilities']()).rejects.toBeInstanceOf(GatewayAuthError); + }); + + it('throws plain Error on non-auth failure (500)', async () => { + mockFetchResponse(500, 'server exploded'); + const client = makeMinimalClient(); + + const promise = client['uploadCapabilities'](); + await expect(promise).rejects.not.toBeInstanceOf(GatewayAuthError); + await expect(promise).rejects.toThrow(/Failed to upload capabilities: 500/); + }); +}); diff --git a/packages/@n8n/computer-use/src/gateway-client.ts b/packages/@n8n/computer-use/src/gateway-client.ts index 9ba5f792e77..ac4070b078b 100644 --- a/packages/@n8n/computer-use/src/gateway-client.ts +++ b/packages/@n8n/computer-use/src/gateway-client.ts @@ -32,6 +32,17 @@ import { formatErrorResult } from './tools/utils'; const MAX_RECONNECT_DELAY_MS = 30_000; const MAX_AUTH_RETRIES = 5; +/** Thrown when the gateway rejects our pairing token with 401/403. */ +export class GatewayAuthError extends Error { + constructor( + readonly status: number, + readonly body: string, + ) { + super(`Gateway rejected token: ${status} ${body}`); + this.name = 'GatewayAuthError'; + } +} + /** Tag tool definitions with a category annotation (mutates in place for efficiency). */ function tagCategory(defs: ToolDefinition[], category: string): ToolDefinition[] { for (const def of defs) { @@ -301,6 +312,9 @@ export class GatewayClient { if (!response.ok) { const text = await response.text(); + if (response.status === 401 || response.status === 403) { + throw new GatewayAuthError(response.status, text); + } throw new Error(`Failed to upload capabilities: ${response.status} ${text}`); } diff --git a/packages/@n8n/computer-use/src/logger.ts b/packages/@n8n/computer-use/src/logger.ts index d026819a56a..0da00cb7f7c 100644 --- a/packages/@n8n/computer-use/src/logger.ts +++ b/packages/@n8n/computer-use/src/logger.ts @@ -259,6 +259,13 @@ export function printAuthFailure(): void { logger.error(` ${pc.red('✗')} Authentication failed — waiting for new pairing token`); } +export function printInvalidToken(url: string): void { + logger.error(` ${pc.red('✗')} Connection token invalid`); + logger.error( + ` ${pc.dim(`Go to ${url} and reconnect n8n Computer Use using a new connection token`)}`, + ); +} + export function printReinitializing(): void { logger.info(` ${pc.magenta('▸')} Re-initializing gateway connection`); }