mirror of
https://github.com/moeru-ai/airi.git
synced 2026-10-29 18:48:55 +08:00
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:
@@ -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=''
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user