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>
This commit is contained in:
Huyvux12
2026-10-03 22:57:27 +00:00
committed by GitHub
co-authored by Columbina
parent c097190cb7
commit 390535bbd4
6 changed files with 187 additions and 2 deletions
+3
View File
@@ -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=''
+9
View File
@@ -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
+1
View File
@@ -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:",
@@ -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<string, unknown>) => Parameters<typeof startAiriClientConnection>[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<void>(() => {}) }, { 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<void>(() => {}) }, { 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<void>()
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<typeof startAiriClientConnection> | 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<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
}
})
})
@@ -1,4 +1,5 @@
import { errorMessageFrom } from '@moeru/std'
import { isAuthenticationServerErrorMessage } from '@proj-airi/server-shared'
interface AiriClientLike {
connect: () => Promise<void>
@@ -10,13 +11,32 @@ interface LoggerLike {
withFields: (fields: Record<string, unknown>) => 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',
+3
View File
@@ -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