/** * Offline pre-install check: load the bundle exactly as the Loader would * (`apply()` on a mock context), verify every tool definition, exercise the QR * login state machine with a fake flow, and serve the panel routes on an * ephemeral local HTTP server. * * Run with: node scripts/self-check.mjs */ import http from 'node:http' import { apply, inject, name } from '../index.js' import { resolveConfig } from '../lib/config.js' import { createAcfunTools, ACFUN_GUIDANCE } from '../lib/tools.js' import { AcfunProvider } from '../lib/provider.js' import { AcfunLoginManager, createCredentialSessionStore, SESSION_RECORD_KEY } from '../lib/login.js' import { createPanelRoutes, PANEL_PATH } from '../lib/panel.js' import { AcfunQrFlow, QR_PATHS } from '../lib/qr-flow.js' import { assertSchemaSupported, sampleValue, validateValue } from './schema-check.mjs' const failures = [] const check = (label, condition, detail = '') => { if (condition) { console.log(` ok ${label}`) } else { failures.push(`${label}${detail ? `: ${detail}` : ''}`) console.log(` FAIL ${label}${detail ? `: ${detail}` : ''}`) } } console.log('plugin exports') check('name is dsh-plugin-acfun', name === 'dsh-plugin-acfun', String(name)) check('inject lists tools and systemPrompt', inject.includes('tools') && inject.includes('systemPrompt'), JSON.stringify(inject)) check('guidance is non-empty prose', typeof ACFUN_GUIDANCE === 'string' && ACFUN_GUIDANCE.length > 80, String(ACFUN_GUIDANCE.length)) check('QR endpoints are documented constants', QR_PATHS.start.startsWith('/rest/pc-direct/qr/') && QR_PATHS.scanResult.includes('scanResult')) console.log('\nmock activation') const registered = [] const sections = [] const routes = [] const mockCtx = { effect(factory) { factory() }, get(service) { if (service === 'credentials') return undefined if (service === 'webServer') return { register: (route) => { routes.push(route); return () => {} } } return undefined }, tools: { register(definition) { registered.push(definition) return () => {} }, }, systemPrompt: { section(section) { sections.push(section) return () => {} }, }, } apply(mockCtx, {}) check('one system-prompt section registered', sections.length === 1, JSON.stringify(sections)) check('section name is tool:acfun', sections[0]?.name === 'tool:acfun', String(sections[0]?.name)) check('twelve tools registered', registered.length === 12, String(registered.length)) check( 'tool names are unique acfun_* names', new Set(registered.map((tool) => tool.name)).size === 12 && registered.every((tool) => tool.name.startsWith('acfun_')), registered.map((tool) => tool.name).join(','), ) check('panel routes registered', routes.length === 1 && routes[0].kind === 'prefix' && routes[0].path === PANEL_PATH, JSON.stringify(routes.map((route) => route.path))) console.log('\ntool definitions') for (const tool of registered) { const params = assertSchemaSupported(tool.parameters, `${tool.name}.parameters`) const output = assertSchemaSupported(tool.output.schema, `${tool.name}.output`) check(`${tool.name}: has a description`, typeof tool.description === 'string' && tool.description.length > 40) check(`${tool.name}: parameters stay in the schema subset`, params.length === 0, params.join('; ')) check(`${tool.name}: parameters are an object root`, tool.parameters.type === 'object' && tool.parameters.additionalProperties === false) check(`${tool.name}: output stays in the schema subset`, output.length === 0, output.join('; ')) check(`${tool.name}: output is an object root`, tool.output.schema.type === 'object') const sample = sampleValue(tool.output.schema) const valueErrors = validateValue(sample, tool.output.schema, `${tool.name}.sample`) check(`${tool.name}: schema sample validates`, valueErrors.length === 0, valueErrors.join('; ')) let rendered try { rendered = tool.output.render({}, sample) } catch (error) { check(`${tool.name}: render does not throw`, false, String(error?.message ?? error)) continue } check( `${tool.name}: render returns text content blocks`, Array.isArray(rendered) && rendered.length > 0 && rendered.every((block) => block?.type === 'text' && typeof block.text === 'string'), ) check(`${tool.name}: render uses its value`, rendered.some((block) => block.text.includes('sample'))) } console.log('\nwrite guard fails closed') const provider = new AcfunProvider({}) const tools = createAcfunTools(provider, resolveConfig({})) const interact = tools.find((tool) => tool.name === 'acfun_interact') const postComment = tools.find((tool) => tool.name === 'acfun_post_comment') const exec = { signal: new AbortController().signal } for (const [tool, args] of [ [interact, { action: 'like', acId: '42588725' }], [postComment, { acId: '42588725', content: 'hello' }], ]) { try { await tool.execute(args, exec) check(`${tool.name}: refuses to write without allowWriteActions`, false, 'execute resolved unexpectedly') } catch (error) { check(`${tool.name}: refuses to write without allowWriteActions`, error?.code === 'WRITE_DISABLED', String(error?.code)) } } const enabledProvider = new AcfunProvider({ allowWriteActions: true }) const enabledTools = createAcfunTools(enabledProvider, resolveConfig({ allowWriteActions: true })) try { await enabledTools.find((tool) => tool.name === 'acfun_interact').execute({ action: 'follow', userId: '1' }, exec) check('write tools require a cookie even when enabled', false, 'execute resolved unexpectedly') } catch (error) { check('write tools require a cookie even when enabled', error?.code === 'LOGIN_REQUIRED', String(error?.code)) } console.log('\nconfig resolution') const withEnv = resolveConfig({}, { ACFUN_COOKIE: 'auth_key=abc; _did=xyz' }) check('env cookie is read through cookieEnv', withEnv.cookie === 'auth_key=abc; _did=xyz' && withEnv.cookieSource === 'env') const literal = resolveConfig({ cookie: 'auth_key=literal', cookieEnv: 'OTHER' }, { OTHER: 'ignored' }) check('literal cookie wins over env', literal.cookie === 'auth_key=literal' && literal.cookieSource === 'config') check('defaults keep writes disabled', resolveConfig(undefined).allowWriteActions === false && resolveConfig(undefined).cookie === '') check('bad config values fall back with warnings', resolveConfig(null).warnings.length > 0) console.log('\nQR login state machine (fake flow, no network)') { const fakeProvider = new AcfunProvider({}) const writes = [] const store = { async read() { return undefined }, async write(cookie) { writes.push(cookie) }, async clear() { writes.push('cleared') }, } let step = 0 const fakeFlow = { async start() { return { ticket: 'T-1', qr: { kind: 'data-url', value: 'data:image/png;base64,AAA' }, expiresAt: Date.now() + 60_000 } }, async waitForScan() { step += 1 return step === 1 ? { state: 'waiting', message: '' } : { state: 'scanned', message: '' } }, async accept() { return { state: 'confirmed', message: 'ok', cookie: 'auth_key=abc; _did=xyz' } }, } const manager = new AcfunLoginManager({ provider: fakeProvider, store, flow: fakeFlow }) check('login is available with a flow', manager.available === true) const started = await manager.start() check('start returns a waiting ticket with a QR image', started.state === 'waiting' && started.ticket === 'T-1' && started.qr?.kind === 'data-url') const deadline = Date.now() + 6000 while (manager.snapshot().state !== 'confirmed' && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 50)) } check('background loop reaches confirmed', manager.snapshot().state === 'confirmed', manager.snapshot().state) check('confirmed session is persisted', writes.includes('auth_key=abc; _did=xyz'), JSON.stringify(writes)) check('provider receives the session cookie', fakeProvider.loggedIn && fakeProvider.describeSession().hasAuthKey) check('session source is qr', manager.authSource === 'qr') const cleared = await manager.logout() check('logout clears state and cookie', cleared.state === 'idle' && !fakeProvider.loggedIn && writes.includes('cleared')) check('QR image data is not exposed in snapshot after login', manager.snapshot().qr === null) } { const store = { async read() { return undefined }, async write() {}, async clear() {} } const manager = new AcfunLoginManager({ provider: new AcfunProvider({}), store, flow: { async start() { return { ticket: 'T-2', qr: { kind: 'data-url', value: 'data:image/png;base64,BBB' }, expiresAt: Date.now() + 200 } }, async waitForScan() { return { state: 'waiting', message: '' } }, }, }) await manager.start() await new Promise((resolve) => setTimeout(resolve, 2600)) check('expired tickets stop polling and report expired', manager.snapshot().state === 'expired', manager.snapshot().state) await manager.cancel() } console.log('\ncredential store') { const calls = [] const fakeCredentials = { async readRecord(key) { calls.push(['read', key]) return { kind: 'grant', payload: { cookie: 'auth_key=stored', savedAt: 1 } } }, async modifyRecord(key, mutate) { calls.push(['modify', key]) await mutate(undefined) }, async deleteRecord(key) { calls.push(['delete', key]) }, } const store = createCredentialSessionStore(fakeCredentials) const loaded = await store.read() check('credential store reads a grant record', loaded?.cookie === 'auth_key=stored' && calls.some(([op, key]) => op === 'read' && key === SESSION_RECORD_KEY)) await store.write('auth_key=next') check('credential store writes through modifyRecord', calls.some(([op]) => op === 'modify')) await store.clear() check('credential store clears the record', calls.some(([op]) => op === 'delete')) } console.log('\npanel routes over local HTTP') { const panelProvider = new AcfunProvider({}) const store = { async read() { return undefined }, async write() {}, async clear() {} } const login = new AcfunLoginManager({ provider: panelProvider, store, flow: { async start() { return { ticket: 'P-1', qr: { kind: 'data-url', value: 'data:image/png;base64,CCC' }, expiresAt: Date.now() + 60_000 } }, async waitForScan() { return { state: 'waiting', message: '' } }, }, }) const panelTools = createAcfunTools(panelProvider, resolveConfig({})) const [route] = createPanelRoutes({ provider: panelProvider, login, version: '0.0.0-test', allowWriteActions: false, cookieSource: () => 'none', tools: panelTools, }) const server = http.createServer(route.handler) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) const base = `http://127.0.0.1:${server.address().port}${PANEL_PATH}` try { const state = await (await fetch(`${base}/state`)).json() check('panel state reports the plugin and tools', state.ok === true && state.plugin.name === 'dsh-plugin-acfun' && state.tools.length === 12) check('panel state does not leak a cookie', state.auth.configured === false && state.auth.hasAuthKey === false) const started = await (await fetch(`${base}/login/start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })).json() check('panel start returns a QR data URL', started.ok === true && started.login.state === 'waiting' && started.login.qr.kind === 'data-url') const polled = await (await fetch(`${base}/login/poll?ticket=${encodeURIComponent(started.login.ticket)}`)).json() check('panel poll returns the in-memory state', polled.ok === true && polled.login.state === 'waiting') const crossed = await fetch(`${base}/state`, { headers: { Origin: 'https://evil.example' } }) check('panel rejects cross-origin browser requests', crossed.status === 403, String(crossed.status)) const unknown = await fetch(`${base}/nope`) check('panel returns 404 for unknown routes', unknown.status === 404, String(unknown.status)) const loggedOut = await (await fetch(`${base}/logout`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })).json() check('panel logout returns to idle', loggedOut.ok === true && loggedOut.login.state === 'idle') } finally { await login.cancel() await new Promise((resolve) => server.close(resolve)) } } console.log('\nqr flow module') { const flow = new AcfunQrFlow({ serviceUrl: 'https://scan.acfun.cn', longPollTimeoutMs: 5000 }) check('flow exposes documented paths', flow.serviceUrl === 'https://scan.acfun.cn') check('start is a function', typeof flow.start === 'function') check('waitForScan is a function', typeof flow.waitForScan === 'function') check('accept is a function', typeof flow.accept === 'function') } if (failures.length > 0) { console.error(`\n${failures.length} check(s) failed`) process.exitCode = 1 } else { console.log('\nall offline checks passed') }