From 390535bbd48d78965c15d0315e35a17a91c7a2cc Mon Sep 17 00:00:00 2001 From: Huyvux12 <152865675+Huyvux12@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:57:27 +0700 Subject: [PATCH] fix(minecraft): explain AIRI authentication failures (#2750) ## Summary Minecraft authentication failures looked like an unavailable AIRI server and produced duplicate warnings. Report the token setup instructions for server authentication errors. - Use the existing server error classifier. - Report authentication once across `onError` and `connect()` rejection. - Keep authentication guidance visible after an earlier network failure. - Clear the warning flags after a successful connection. - Add `AIRI_WS_TOKEN` to the environment template and setup instructions. Related to #2071 and the maintainer's token diagnosis. This change corrects diagnostics and setup guidance. It does not replace the SDK authentication or retry policy. ## Behavior evidence | Scenario | Before | After | | --- | --- | --- | | Missing or invalid token | Server-unavailable or generic stopped-retrying warning | Token setup instructions | | Same authentication error reaches both callbacks | Two warnings | One authentication warning | | Network failure precedes authentication failure | Initial warning hides the later cause | Authentication guidance remains visible | | Connection succeeds, then authentication fails again | Generic connection warning | New token guidance | ## Verification Updated against `main` at `450c5d81dcf2b63811c2aa554bb562ae6fb758a4`. Merge commit: `a3967e615a3c6ef1d1166355dc0cb11dfd4c14f7`. The published tree matches the locally tested tree: `01799b54b8e264d56a9234488cc8246ce4b2eeb7`. The PR still changes only the original six files. Checks on the combined revision: - `pnpm install --frozen-lockfile --ignore-scripts`: passed. - `pnpm run build:packages`: 34 tasks passed. - `pnpm -F @proj-airi/minecraft-bot exec vitest run src/airi`: 3 files, 14 tests passed. Includes real loopback WebSocket handshakes with missing and invalid tokens. - `pnpm exec vitest run --config apps/stage-tamagotchi/vitest.node.config.ts src/main/services/airi/plugins/index.test.ts`: 64 tests passed. - `pnpm lint`: passed with 0 errors and 694 warnings. - `NODE_OPTIONS=--max-old-space-size=4096 pnpm exec turbo run typecheck --concurrency=1 -F='./packages/*' -F='./apps/*' -F='./server/**' -F='./services/*' -F='./docs'`: 55 tasks passed. Default parallel runs exceeded the local memory limits. - `git diff --check`: passed. Remaining limits: - `pnpm -F @proj-airi/minecraft-bot typecheck`: 12 diagnostics outside the changed files. A detached worktree at the current main commit reports the identical 12 diagnostics with the same dependencies. - `pnpm exec vitest run`: 2,455 tests passed, 2 failed, 3 skipped, and 1 browser setup error. One failure needs Chromium. The other is an IPv6 auth rate-limit test timeout. The same timeout also occurs in a detached worktree at current main. The previous extension-host CI failure did not recur in this run. - `pnpm exec playwright install chromium` failed because the browser download was invalid. The complete `pnpm test:run` browser sequence remains unverified. - Local checks used Node 24.19.0 and pnpm 11.25.0. CI uses Node 26.7.0 and the repository package manager. - A real Minecraft gameplay session was not run. The new upstream CI run requires maintainer approval before it can execute: https://github.com/moeru-ai/airi/actions/runs/37126204012 Co-authored-by: Columbina <140679517+0xSelenicDove@users.noreply.github.com> --- integrations/minecraft/.env | 3 + integrations/minecraft/README.md | 9 ++ integrations/minecraft/package.json | 1 + .../src/airi/start-background-client.test.ts | 145 ++++++++++++++++++ .../src/airi/start-background-client.ts | 28 +++- pnpm-lock.yaml | 3 + 6 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 integrations/minecraft/src/airi/start-background-client.test.ts diff --git a/integrations/minecraft/.env b/integrations/minecraft/.env index 963c684de1..9d6c73c004 100644 --- a/integrations/minecraft/.env +++ b/integrations/minecraft/.env @@ -3,6 +3,9 @@ OPENAI_API_KEY='' OPENAI_MODEL='deepseek-chat' OPENAI_REASONING_MODEL='deepseek-reasoner' +# Copy the WebSocket token from AIRI Settings -> Connection into .env.local. +AIRI_WS_TOKEN='' + BOT_USERNAME='' BOT_HOSTNAME='' BOT_PORT='' diff --git a/integrations/minecraft/README.md b/integrations/minecraft/README.md index c88bc948b0..e4f2e153df 100644 --- a/integrations/minecraft/README.md +++ b/integrations/minecraft/README.md @@ -32,6 +32,15 @@ Treat this service as a local-development and trusted-server tool only. 3. Edit `integrations/minecraft/.env.local`. + In AIRI, open **Settings -> Connection** and copy the WebSocket authentication token. + Set `AIRI_WS_TOKEN` to that token in `.env.local`: + + ```dotenv + AIRI_WS_TOKEN='paste-your-token-here' + ``` + + If the token changes, update `.env.local` and restart the bot. + 4. Start the service: ```bash diff --git a/integrations/minecraft/package.json b/integrations/minecraft/package.json index 5b4eb858c7..1e9a8c16f5 100644 --- a/integrations/minecraft/package.json +++ b/integrations/minecraft/package.json @@ -16,6 +16,7 @@ "@modelcontextprotocol/sdk": "catalog:", "@moeru/std": "catalog:", "@proj-airi/server-sdk": "workspace:*", + "@proj-airi/server-shared": "workspace:*", "@xsai/generate-text": "catalog:", "@xsai/shared-chat": "catalog:", "alien-signals": "catalog:", diff --git a/integrations/minecraft/src/airi/start-background-client.test.ts b/integrations/minecraft/src/airi/start-background-client.test.ts new file mode 100644 index 0000000000..210bdb57f3 --- /dev/null +++ b/integrations/minecraft/src/airi/start-background-client.test.ts @@ -0,0 +1,145 @@ +import { once } from 'node:events' + +import { Client, parseEvent } from '@proj-airi/server-sdk' +import { describe, expect, it, vi } from 'vitest' +import { WebSocketServer } from 'ws' + +import { startAiriClientConnection } from './start-background-client' + +function createLogger() { + const logger = { + log: vi.fn<(message: string) => void>(), + warn: vi.fn<(message: string) => void>(), + withFields: vi.fn<(fields: Record) => Parameters[1]['logger']>(), + } + logger.withFields.mockReturnValue(logger) + return logger +} + +describe('background AIRI connection', () => { + // https://github.com/moeru-ai/airi/issues/2071 + // ROOT CAUSE: + // Authentication errors used the network retry warning and a second generic warning. + // Classify server authentication errors and report token setup once per connection. + it.each(['invalid token', 'not authenticated', 'must authenticate before announcing'])( + 'explains how to correct %s (Issue #2071)', + async (message) => { + const error = new Error(message) + const logger = createLogger() + const client = { connect: vi.fn().mockRejectedValue(error) } + const lifecycle = startAiriClientConnection(client, { logger, url: 'ws://localhost:6121/ws' }) + + lifecycle.reportUnavailable(error) + await vi.waitFor(() => expect(logger.warn).toHaveBeenCalled()) + await Promise.resolve() + + expect(logger.warn).toHaveBeenCalledTimes(1) + expect(logger.warn.mock.calls[0][0]).toContain('Settings -> Connection') + expect(logger.warn.mock.calls[0][0]).toContain('AIRI_WS_TOKEN') + expect(logger.warn.mock.calls[0][0]).not.toContain('retrying in background') + expect(logger.withFields).toHaveBeenCalledWith({ url: 'ws://localhost:6121/ws', error: message }) + }, + ) + + it('keeps background retry guidance for network failures', async () => { + const logger = createLogger() + const lifecycle = startAiriClientConnection({ connect: () => new Promise(() => {}) }, { logger, url: 'ws://localhost:6121/ws' }) + lifecycle.reportUnavailable(new Error('ECONNREFUSED')) + lifecycle.reportUnavailable(new Error('ECONNREFUSED')) + + expect(logger.warn).toHaveBeenCalledTimes(1) + expect(logger.warn.mock.calls[0][0]).toContain('retrying in background') + expect(logger.warn.mock.calls[0][0]).not.toContain('AIRI_WS_TOKEN') + }) + + it('reports authentication failure after an earlier network failure', () => { + const logger = createLogger() + const lifecycle = startAiriClientConnection({ connect: () => new Promise(() => {}) }, { logger, url: 'ws://localhost:6121/ws' }) + lifecycle.reportUnavailable(new Error('ECONNREFUSED')) + lifecycle.reportUnavailable(new Error('invalid token')) + + expect(logger.warn).toHaveBeenCalledTimes(2) + expect(logger.warn.mock.calls[1][0]).toContain('AIRI_WS_TOKEN') + }) + + it('explains authentication when connect rejects without an error callback', async () => { + const logger = createLogger() + startAiriClientConnection({ connect: () => Promise.reject(new Error('invalid token')) }, { logger, url: 'ws://localhost:6121/ws' }) + + await vi.waitFor(() => expect(logger.warn).toHaveBeenCalledTimes(1)) + expect(logger.warn.mock.calls[0][0]).toContain('AIRI_WS_TOKEN') + }) + + it('reports a new authentication failure after the connection succeeds', async () => { + const connection = Promise.withResolvers() + const logger = createLogger() + const lifecycle = startAiriClientConnection({ connect: () => connection.promise }, { logger, url: 'ws://localhost:6121/ws' }) + lifecycle.reportUnavailable(new Error('invalid token')) + lifecycle.reportUnavailable(new Error('invalid token')) + expect(logger.warn).toHaveBeenCalledTimes(1) + + connection.resolve() + await vi.waitFor(() => expect(logger.log).toHaveBeenCalledTimes(1)) + lifecycle.reportUnavailable(new Error('invalid token')) + + expect(logger.warn).toHaveBeenCalledTimes(2) + expect(logger.warn.mock.calls[1][0]).toContain('AIRI_WS_TOKEN') + }) + + // https://github.com/moeru-ai/airi/issues/2071 + // ROOT CAUSE: + // The SDK reports protocol authentication errors through onError and connect rejection. + // Both paths now share token guidance and suppress duplicate authentication warnings. + it.each([ + { token: undefined, message: 'must authenticate before announcing', event: 'extension:module:announce' }, + { token: 'invalid-test-token', message: 'invalid token', event: 'module:authenticate' }, + ])('reports $message from a real WebSocket handshake (Issue #2071)', async ({ token, message, event }) => { + const server = new WebSocketServer({ host: '127.0.0.1', port: 0 }) + await once(server, 'listening') + const address = server.address() + if (!address || typeof address === 'string') + throw new Error('Expected a TCP listener') + + const received = vi.fn() + server.on('connection', (socket) => { + socket.on('message', (data) => { + received(parseEvent(data.toString()).type) + socket.send(JSON.stringify({ + type: 'error', + data: { message }, + metadata: { + source: { kind: 'plugin', plugin: { id: 'server' }, id: 'server-1' }, + event: { id: 'auth-error' }, + }, + })) + }) + }) + + const url = `ws://127.0.0.1:${address.port}/ws` + const logger = createLogger() + let lifecycle: ReturnType | undefined + const client = new Client({ + name: 'minecraft', + url, + token, + autoConnect: false, + autoReconnect: false, + heartbeat: false, + onError: error => lifecycle?.reportUnavailable(error), + }) + try { + lifecycle = startAiriClientConnection(client, { logger, url }) + await vi.waitFor(() => expect(client.connectionStatus).toBe('failed')) + expect(received).toHaveBeenCalledWith(event) + expect(logger.warn).toHaveBeenCalledTimes(1) + expect(logger.warn.mock.calls[0][0]).toContain('AIRI_WS_TOKEN') + expect(logger.log).not.toHaveBeenCalled() + } + finally { + client.close() + for (const socket of server.clients) + socket.terminate() + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) + } + }) +}) diff --git a/integrations/minecraft/src/airi/start-background-client.ts b/integrations/minecraft/src/airi/start-background-client.ts index 6dccd39c74..9586cc59eb 100644 --- a/integrations/minecraft/src/airi/start-background-client.ts +++ b/integrations/minecraft/src/airi/start-background-client.ts @@ -1,4 +1,5 @@ import { errorMessageFrom } from '@moeru/std' +import { isAuthenticationServerErrorMessage } from '@proj-airi/server-shared' interface AiriClientLike { connect: () => Promise @@ -10,13 +11,32 @@ interface LoggerLike { withFields: (fields: Record) => LoggerLike } +/** Starts a background connection and reports each failure category once until the connection succeeds. */ export function startAiriClientConnection(client: AiriClientLike, deps: { logger: LoggerLike url: string }) { let unavailableReported = false + let authenticationReported = false + + const reportAuthenticationFailure = (error: unknown) => { + const message = errorMessageFrom(error) ?? 'Unknown error' + if (!isAuthenticationServerErrorMessage(message)) + return false + + if (!authenticationReported) { + authenticationReported = true + deps.logger.withFields({ url: deps.url, error: message }).warn( + 'AIRI authentication failed. Copy the token from AIRI Settings -> Connection. Set AIRI_WS_TOKEN in integrations/minecraft/.env.local to that token. Then restart the bot.', + ) + } + return true + } const reportUnavailable = (error: unknown) => { + if (reportAuthenticationFailure(error)) + return + if (unavailableReported) return @@ -24,13 +44,13 @@ export function startAiriClientConnection(client: AiriClientLike, deps: { deps.logger.withFields({ url: deps.url, error: errorMessageFrom(error) ?? 'Unknown error', - }).warn('AIRI server is unavailable; continuing startup without AIRI and retrying in background') + }).warn('AIRI server is unavailable. Continuing startup without AIRI and retrying in background') } const reportDisconnected = () => { deps.logger.withFields({ url: deps.url, - }).warn('AIRI server connection closed; retrying in background') + }).warn('AIRI server connection closed. Retrying in background') } void client.connect() @@ -43,8 +63,12 @@ export function startAiriClientConnection(client: AiriClientLike, deps: { : 'Connected to AIRI server', ) unavailableReported = false + authenticationReported = false }) .catch((error) => { + if (reportAuthenticationFailure(error)) + return + deps.logger.withFields({ url: deps.url, error: errorMessageFrom(error) ?? 'Unknown error', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 704c5905ba..9c7d48828c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3728,6 +3728,9 @@ importers: '@proj-airi/server-sdk': specifier: workspace:* version: link:../../packages/server-sdk + '@proj-airi/server-shared': + specifier: workspace:* + version: link:../../packages/server-shared '@xsai/generate-text': specifier: 'catalog:' version: 0.5.1