- Add Vite as alternative build (vite.config.ts, scripts/vite-plugin-*)
- Add dab04af7 RSS fix (distRoot, ripgrep path, post-build)
- Pull in packages/builtin-tools/ (+354 files)
- Pull in packages/agent-tools/, mcp-client/, acp-link/, weixin/
- Copy defines.ts from upstream for Vite compatibility
- Update package.json with Vite scripts and deps
307 lines
9.4 KiB
TypeScript
307 lines
9.4 KiB
TypeScript
import { describe, test, expect, mock } from 'bun:test'
|
|
import {
|
|
__testing,
|
|
decodeClientWsMessage,
|
|
MAX_CLIENT_WS_PAYLOAD_BYTES,
|
|
resolveNewSessionPermissionMode,
|
|
type ServerConfig,
|
|
} from '../server.js'
|
|
import {
|
|
authTokensEqual,
|
|
decodeWebSocketAuthProtocol,
|
|
encodeWebSocketAuthProtocol,
|
|
extractWebSocketAuthToken,
|
|
} from '../ws-auth.js'
|
|
import { buildRcsWsUrl } from '../rcs-upstream.js'
|
|
|
|
function makeTestWs(sent: unknown[]) {
|
|
type TestWs = Parameters<typeof __testing.dispatchClientMessage>[0]
|
|
|
|
return {
|
|
readyState: 1,
|
|
send: mock((message: string) => {
|
|
sent.push(JSON.parse(message))
|
|
}),
|
|
close: mock(() => {}),
|
|
raw: null,
|
|
isInner: false,
|
|
url: '',
|
|
origin: '',
|
|
protocol: '',
|
|
} as unknown as TestWs
|
|
}
|
|
|
|
describe('Server HTTP endpoints', () => {
|
|
test('package.json has correct bin and main entries', async () => {
|
|
const pkg = await import('../../package.json', { with: { type: 'json' } })
|
|
expect(pkg.default.name).toBe('acp-link')
|
|
expect(pkg.default.main).toBe('./dist/server.js')
|
|
expect(pkg.default.bin).toBeDefined()
|
|
expect(pkg.default.bin['acp-link']).toBe('dist/cli/bin.js')
|
|
})
|
|
|
|
test('ServerConfig interface accepts all expected fields', () => {
|
|
const config: ServerConfig = {
|
|
port: 9315,
|
|
host: 'localhost',
|
|
command: 'echo',
|
|
args: [],
|
|
cwd: '/tmp',
|
|
debug: false,
|
|
token: 'test-token',
|
|
https: false,
|
|
}
|
|
expect(config.port).toBe(9315)
|
|
expect(config.token).toBe('test-token')
|
|
})
|
|
|
|
test('ServerConfig allows optional fields to be omitted', () => {
|
|
const config: ServerConfig = {
|
|
port: 9315,
|
|
host: 'localhost',
|
|
command: 'echo',
|
|
args: [],
|
|
cwd: '/tmp',
|
|
}
|
|
expect(config.debug).toBeUndefined()
|
|
expect(config.token).toBeUndefined()
|
|
expect(config.https).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('WebSocket message types', () => {
|
|
const clientMessageTypes = [
|
|
'connect',
|
|
'disconnect',
|
|
'new_session',
|
|
'prompt',
|
|
'permission_response',
|
|
'cancel',
|
|
'set_session_model',
|
|
'list_sessions',
|
|
'load_session',
|
|
'resume_session',
|
|
'ping',
|
|
]
|
|
|
|
test('all client message types are recognized', () => {
|
|
expect(clientMessageTypes.length).toBe(11)
|
|
expect(clientMessageTypes).toContain('ping')
|
|
expect(clientMessageTypes).toContain('connect')
|
|
expect(clientMessageTypes).toContain('cancel')
|
|
})
|
|
|
|
test('decodes supported client message payloads', () => {
|
|
expect(decodeClientWsMessage('{"type":"ping"}')).toEqual({ type: 'ping' })
|
|
expect(
|
|
decodeClientWsMessage(
|
|
Buffer.from('{"type":"prompt","payload":{"content":[]}}'),
|
|
),
|
|
).toEqual({ type: 'prompt', payload: { content: [] } })
|
|
expect(
|
|
decodeClientWsMessage(
|
|
new TextEncoder().encode('{"type":"cancel"}').buffer,
|
|
),
|
|
).toEqual({ type: 'cancel' })
|
|
expect(
|
|
decodeClientWsMessage([
|
|
Buffer.from('{"type":"list_sessions","payload":{"cursor":"'),
|
|
Buffer.from('next"}}'),
|
|
]),
|
|
).toEqual({
|
|
type: 'list_sessions',
|
|
payload: { cwd: undefined, cursor: 'next' },
|
|
})
|
|
})
|
|
|
|
test('rejects malformed typed client payloads', () => {
|
|
expect(() => decodeClientWsMessage('{"type":"prompt"}')).toThrow(
|
|
'Invalid prompt payload',
|
|
)
|
|
expect(() =>
|
|
decodeClientWsMessage('{"type":"load_session","payload":{}}'),
|
|
).toThrow('Invalid load_session payload')
|
|
expect(() => decodeClientWsMessage('{"type":"unknown"}')).toThrow(
|
|
'Unknown message type',
|
|
)
|
|
expect(() =>
|
|
decodeClientWsMessage(
|
|
'{"type":"new_session","payload":{"permissionMode":123}}',
|
|
),
|
|
).toThrow('Invalid new_session.permissionMode')
|
|
expect(() =>
|
|
decodeClientWsMessage(
|
|
'{"type":"new_session","payload":{"permissionMode":{}}}',
|
|
),
|
|
).toThrow('Invalid new_session.permissionMode')
|
|
expect(() =>
|
|
decodeClientWsMessage(
|
|
'{"type":"new_session","payload":{"permissionMode":null}}',
|
|
),
|
|
).toThrow('Invalid new_session.permissionMode')
|
|
})
|
|
|
|
test('rejects oversized client message payloads before decoding', () => {
|
|
const payload = 'x'.repeat(MAX_CLIENT_WS_PAYLOAD_BYTES + 1)
|
|
expect(() => decodeClientWsMessage(payload)).toThrow(
|
|
'WebSocket message too large',
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('WebSocket auth protocol', () => {
|
|
test('round-trips tokens through a WebSocket subprotocol token', () => {
|
|
const protocol = encodeWebSocketAuthProtocol('secret/token+with=symbols')
|
|
expect(protocol).toStartWith('rcs.auth.')
|
|
expect(protocol).not.toContain('secret/token')
|
|
expect(decodeWebSocketAuthProtocol(protocol)).toBe(
|
|
'secret/token+with=symbols',
|
|
)
|
|
})
|
|
|
|
test('ignores query-token style inputs', () => {
|
|
expect(decodeWebSocketAuthProtocol(undefined)).toBeUndefined()
|
|
expect(decodeWebSocketAuthProtocol('token=secret')).toBeUndefined()
|
|
expect(decodeWebSocketAuthProtocol('other, rcs.auth.')).toBeUndefined()
|
|
})
|
|
|
|
test('prefers Authorization headers and supports protocol auth', () => {
|
|
expect(
|
|
extractWebSocketAuthToken({
|
|
authorization: 'Bearer header-token',
|
|
protocol: encodeWebSocketAuthProtocol('protocol-token'),
|
|
}),
|
|
).toBe('header-token')
|
|
expect(
|
|
extractWebSocketAuthToken({
|
|
protocol: encodeWebSocketAuthProtocol('protocol-token'),
|
|
}),
|
|
).toBe('protocol-token')
|
|
})
|
|
|
|
test('compares auth tokens through the shared constant-time path', () => {
|
|
expect(authTokensEqual('secret-token', 'secret-token')).toBe(true)
|
|
expect(authTokensEqual('secret-token', 'wrong-token')).toBe(false)
|
|
expect(authTokensEqual(undefined, 'secret-token')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('RCS upstream URL normalization', () => {
|
|
test('removes legacy token query params from WebSocket URLs', () => {
|
|
expect(
|
|
buildRcsWsUrl('http://example.test/acp/ws?token=old-secret&x=1'),
|
|
).toBe('ws://example.test/acp/ws?x=1')
|
|
})
|
|
|
|
test('adds /acp/ws for base URLs', () => {
|
|
expect(buildRcsWsUrl('https://example.test/')).toBe(
|
|
'wss://example.test/acp/ws',
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('permission mode resolution', () => {
|
|
test('uses client requested non-bypass modes', () => {
|
|
expect(resolveNewSessionPermissionMode('plan', 'acceptEdits')).toBe('plan')
|
|
})
|
|
|
|
test('uses local default when client does not request a mode', () => {
|
|
expect(resolveNewSessionPermissionMode(undefined, 'acceptEdits')).toBe(
|
|
'acceptEdits',
|
|
)
|
|
})
|
|
|
|
test('rejects client requested bypassPermissions without local default', () => {
|
|
expect(() =>
|
|
resolveNewSessionPermissionMode('bypassPermissions', 'acceptEdits'),
|
|
).toThrow('bypassPermissions requires local ACP_PERMISSION_MODE')
|
|
expect(() =>
|
|
resolveNewSessionPermissionMode('bypass', 'acceptEdits'),
|
|
).toThrow('bypassPermissions requires local ACP_PERMISSION_MODE')
|
|
expect(() =>
|
|
resolveNewSessionPermissionMode('bypasspermissions', 'acceptEdits'),
|
|
).toThrow('bypassPermissions requires local ACP_PERMISSION_MODE')
|
|
expect(() =>
|
|
resolveNewSessionPermissionMode('bypassPermissions', undefined),
|
|
).toThrow('bypassPermissions requires local ACP_PERMISSION_MODE')
|
|
})
|
|
|
|
test('rejects unknown client permission modes before forwarding', () => {
|
|
expect(() =>
|
|
resolveNewSessionPermissionMode('unknown-mode', 'acceptEdits'),
|
|
).toThrow('Invalid permissionMode: unknown-mode')
|
|
})
|
|
|
|
test('allows bypassPermissions when local default already enables it', () => {
|
|
expect(
|
|
resolveNewSessionPermissionMode('bypassPermissions', 'bypassPermissions'),
|
|
).toBe('bypassPermissions')
|
|
expect(resolveNewSessionPermissionMode('bypass', 'bypassPermissions')).toBe(
|
|
'bypassPermissions',
|
|
)
|
|
expect(resolveNewSessionPermissionMode('bypassPermissions', 'bypass')).toBe(
|
|
'bypassPermissions',
|
|
)
|
|
})
|
|
|
|
test('new_session rejects client bypass before forwarding to the agent', async () => {
|
|
const sent: unknown[] = []
|
|
const ws = makeTestWs(sent)
|
|
const originalTestInternals = process.env.ACP_LINK_TEST_INTERNALS
|
|
process.env.ACP_LINK_TEST_INTERNALS = '1'
|
|
let unregisterClient = () => {}
|
|
let restoreMode = () => {}
|
|
|
|
try {
|
|
const newSession = mock(async () => ({
|
|
sessionId: 'should-not-be-created',
|
|
}))
|
|
unregisterClient = __testing.registerClient(ws, {
|
|
connection: { newSession },
|
|
})
|
|
restoreMode = __testing.setDefaultPermissionMode('acceptEdits')
|
|
|
|
await __testing.dispatchClientMessage(ws, {
|
|
type: 'new_session',
|
|
payload: {
|
|
cwd: '/tmp',
|
|
permissionMode: 'bypass',
|
|
},
|
|
})
|
|
|
|
expect(newSession).not.toHaveBeenCalled()
|
|
expect(__testing.getClientSessionId(ws)).toBeNull()
|
|
expect(sent).toEqual([
|
|
{
|
|
type: 'error',
|
|
payload: {
|
|
message: expect.stringContaining(
|
|
'bypassPermissions requires local ACP_PERMISSION_MODE',
|
|
),
|
|
},
|
|
},
|
|
])
|
|
} finally {
|
|
restoreMode()
|
|
unregisterClient()
|
|
if (originalTestInternals === undefined) {
|
|
delete process.env.ACP_LINK_TEST_INTERNALS
|
|
} else {
|
|
process.env.ACP_LINK_TEST_INTERNALS = originalTestInternals
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('Heartbeat constants', () => {
|
|
test('PERMISSION_TIMEOUT_MS is 5 minutes', () => {
|
|
const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000
|
|
expect(PERMISSION_TIMEOUT_MS).toBe(300_000)
|
|
})
|
|
|
|
test('HEARTBEAT_INTERVAL_MS is 30 seconds', () => {
|
|
const HEARTBEAT_INTERVAL_MS = 30_000
|
|
expect(HEARTBEAT_INTERVAL_MS).toBe(30_000)
|
|
})
|
|
})
|