fix(telegram): complete secure agent control loop #1130

This commit is contained in:
程序员阿江(Relakkes)
2026-07-28 21:38:03 +08:00
parent 977de9f9eb
commit d141543794
11 changed files with 813 additions and 158 deletions
+160 -69
View File
@@ -25,20 +25,29 @@ describe('AdapterHttpClient', () => {
it('createSession calls POST /api/sessions', async () => {
const mockSessionId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
globalThis.fetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({ sessionId: mockSessionId }), {
status: 201,
headers: { 'Content-Type': 'application/json' },
}))
) as any
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-'))
try {
client = new AdapterHttpClient('ws://127.0.0.1:3456', { allowedProjectRoots: [rootDir] })
globalThis.fetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({ sessionId: mockSessionId }), {
status: 201,
headers: { 'Content-Type': 'application/json' },
}))
) as any
const sessionId = await client.createSession('/path/to/project')
expect(sessionId).toBe(mockSessionId)
const sessionId = await client.createSession(rootDir)
expect(sessionId).toBe(mockSessionId)
const call = (globalThis.fetch as any).mock.calls[0]
expect(call[0]).toBe('http://127.0.0.1:3456/api/sessions')
const body = JSON.parse(call[1].body)
expect(body.workDir).toBe('/path/to/project')
const call = (globalThis.fetch as any).mock.calls[0]
expect(call[0]).toBe('http://127.0.0.1:3456/api/sessions')
const body = JSON.parse(call[1].body)
expect(body).toEqual({
workDir: fs.realpathSync(rootDir),
permissionMode: 'default',
})
} finally {
fs.rmSync(rootDir, { recursive: true, force: true })
}
})
it('authenticates requests with the desktop local access token', async () => {
@@ -54,18 +63,51 @@ describe('AdapterHttpClient', () => {
})
it('listRecentProjects calls GET /api/sessions/recent-projects', async () => {
const mockProjects = [
{ projectName: 'my-app', realPath: '/home/user/my-app', sessionCount: 3 },
]
globalThis.fetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({ projects: mockProjects }), {
headers: { 'Content-Type': 'application/json' },
}))
) as any
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-'))
const projectDir = fs.mkdtempSync(path.join(rootDir, 'project-'))
try {
client = new AdapterHttpClient('ws://127.0.0.1:3456', { allowedProjectRoots: [rootDir] })
const mockProjects = [
{ projectName: 'my-app', realPath: projectDir, sessionCount: 3 },
]
globalThis.fetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({ projects: mockProjects }), {
headers: { 'Content-Type': 'application/json' },
}))
) as any
const projects = await client.listRecentProjects()
expect(projects).toHaveLength(1)
expect(projects[0].projectName).toBe('my-app')
const projects = await client.listRecentProjects()
expect(projects).toHaveLength(1)
expect(projects[0].projectName).toBe('my-app')
} finally {
fs.rmSync(rootDir, { recursive: true, force: true })
}
})
it('filters recent projects before index, name, and fuzzy matching', async () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-'))
const allowedDir = fs.mkdtempSync(path.join(rootDir, 'allowed-'))
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'outside-'))
try {
client = new AdapterHttpClient('ws://127.0.0.1:3456', { allowedProjectRoots: [rootDir] })
globalThis.fetch = mock(() =>
Promise.resolve(Response.json({
projects: [
{ projectName: 'secret', realPath: outsideDir, sessionCount: 2 },
{ projectName: 'allowed', realPath: allowedDir, sessionCount: 1 },
],
}))
) as any
await expect(client.matchProject('1')).resolves.toMatchObject({
project: { projectName: 'allowed' },
})
await expect(client.matchProject('secret')).resolves.toEqual({})
await expect(client.matchProject(outsideDir)).resolves.toEqual({})
} finally {
fs.rmSync(rootDir, { recursive: true, force: true })
fs.rmSync(outsideDir, { recursive: true, force: true })
}
})
it('matchProject accepts an absolute local project path inside an allowed root without recent history', async () => {
@@ -132,6 +174,29 @@ describe('AdapterHttpClient', () => {
)
})
it('sessionExists rejects sessions outside the root or using bypassPermissions', async () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-'))
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'outside-'))
try {
client = new AdapterHttpClient('ws://127.0.0.1:3456', { allowedProjectRoots: [rootDir] })
globalThis.fetch = mock((url: string) => {
const unsafe = url.endsWith('/outside')
return Promise.resolve(Response.json({
status: {
workDir: unsafe ? outsideDir : rootDir,
permissionMode: unsafe ? 'default' : 'bypassPermissions',
},
}))
}) as any
await expect(client.sessionExists('outside')).resolves.toBe(false)
await expect(client.sessionExists('bypass')).resolves.toBe(false)
} finally {
fs.rmSync(rootDir, { recursive: true, force: true })
fs.rmSync(outsideDir, { recursive: true, force: true })
}
})
it('getGitInfo calls GET /api/sessions/:id/git-info', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({
@@ -172,33 +237,53 @@ describe('AdapterHttpClient', () => {
})
it('listSessions calls GET /api/sessions with project and pagination query', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({
sessions: [
{
id: 'session-1',
title: 'Fix Telegram menu',
createdAt: '2026-06-09T00:00:00.000Z',
modifiedAt: '2026-06-09T01:00:00.000Z',
messageCount: 3,
projectPath: '-repo',
projectRoot: '/repo',
workDir: '/repo',
workDirExists: true,
},
],
total: 1,
}), {
headers: { 'Content-Type': 'application/json' },
}))
) as any
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-'))
try {
client = new AdapterHttpClient('ws://127.0.0.1:3456', { allowedProjectRoots: [rootDir] })
globalThis.fetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({
sessions: [
{
id: 'session-1',
title: 'Fix Telegram menu',
createdAt: '2026-06-09T00:00:00.000Z',
modifiedAt: '2026-06-09T01:00:00.000Z',
messageCount: 3,
projectPath: rootDir,
projectRoot: rootDir,
workDir: rootDir,
workDirExists: true,
permissionMode: 'default',
},
{
id: 'unsafe-session',
title: 'Unsafe',
createdAt: '2026-06-09T00:00:00.000Z',
modifiedAt: '2026-06-09T01:00:00.000Z',
messageCount: 1,
projectPath: rootDir,
projectRoot: rootDir,
workDir: rootDir,
workDirExists: true,
permissionMode: 'bypassPermissions',
},
],
total: 2,
}), {
headers: { 'Content-Type': 'application/json' },
}))
) as any
const result = await client.listSessions({ project: '/repo', limit: 10, offset: 5 })
const result = await client.listSessions({ project: rootDir, limit: 10, offset: 5 })
expect(result.sessions[0]?.id).toBe('session-1')
expect((globalThis.fetch as any).mock.calls[0][0]).toBe(
'http://127.0.0.1:3456/api/sessions?project=%2Frepo&limit=10&offset=5',
)
expect(result.sessions.map((session) => session.id)).toEqual(['session-1'])
expect(result.total).toBe(1)
expect((globalThis.fetch as any).mock.calls[0][0]).toBe(
`http://127.0.0.1:3456/api/sessions?project=${encodeURIComponent(rootDir)}&limit=10&offset=5`,
)
} finally {
fs.rmSync(rootDir, { recursive: true, force: true })
}
})
it('lists and activates providers through the server provider API', async () => {
@@ -264,28 +349,34 @@ describe('AdapterHttpClient', () => {
})
it('lists skills for a cwd through the server skills API', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({
skills: [
{
name: 'reviewer',
description: 'Review code',
source: 'user',
userInvocable: true,
contentLength: 120,
hasDirectory: true,
},
],
}), {
headers: { 'Content-Type': 'application/json' },
}))
) as any
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'im-root-'))
try {
client = new AdapterHttpClient('ws://127.0.0.1:3456', { allowedProjectRoots: [rootDir] })
globalThis.fetch = mock(() =>
Promise.resolve(new Response(JSON.stringify({
skills: [
{
name: 'reviewer',
description: 'Review code',
source: 'user',
userInvocable: true,
contentLength: 120,
hasDirectory: true,
},
],
}), {
headers: { 'Content-Type': 'application/json' },
}))
) as any
const result = await client.listSkills('/repo')
const result = await client.listSkills(rootDir)
expect(result.skills[0]?.name).toBe('reviewer')
expect((globalThis.fetch as any).mock.calls[0][0]).toBe(
'http://127.0.0.1:3456/api/skills?cwd=%2Frepo',
)
expect(result.skills[0]?.name).toBe('reviewer')
expect((globalThis.fetch as any).mock.calls[0][0]).toBe(
`http://127.0.0.1:3456/api/skills?cwd=${encodeURIComponent(fs.realpathSync(rootDir))}`,
)
} finally {
fs.rmSync(rootDir, { recursive: true, force: true })
}
})
})
@@ -0,0 +1,166 @@
import { afterEach, describe, expect, it } from 'bun:test'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { IMAGE_MAX_BYTES } from '../attachment-limits.js'
import { loadSafeOutboundImage, sendSafeOutboundImage } from '../outbound-image.js'
const temporaryRoots: string[] = []
function makeTempRoot(prefix: string): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix))
temporaryRoots.push(root)
return root
}
afterEach(() => {
for (const root of temporaryRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true })
}
})
describe('loadSafeOutboundImage', () => {
it('requires an active session work directory', async () => {
const result = await loadSafeOutboundImage({
id: 'missing-session',
source: { kind: 'base64', data: 'cG5n', mime: 'image/png' },
}, null)
expect(result).toEqual({
ok: false,
reason: 'missing session work directory',
})
})
it('loads a bounded image inside the active session work directory', async () => {
const root = makeTempRoot('outbound-root-')
const imagePath = path.join(root, 'result.png')
fs.writeFileSync(imagePath, Buffer.from('png'))
const result = await loadSafeOutboundImage({
id: 'inside',
source: { kind: 'path', path: imagePath, mime: 'image/png' },
}, root)
expect(result.ok).toBe(true)
if (result.ok) expect(result.buffer.toString()).toBe('png')
})
it('rejects paths outside the session root, including symlink escapes', async () => {
const root = makeTempRoot('outbound-root-')
const outside = makeTempRoot('outbound-outside-')
const secretPath = path.join(outside, 'secret.png')
const symlinkPath = path.join(root, 'escape.png')
fs.writeFileSync(secretPath, Buffer.from('secret'))
fs.symlinkSync(secretPath, symlinkPath)
const direct = await loadSafeOutboundImage({
id: 'outside',
source: { kind: 'path', path: secretPath, mime: 'image/png' },
}, root)
const symlink = await loadSafeOutboundImage({
id: 'symlink',
source: { kind: 'path', path: symlinkPath, mime: 'image/png' },
}, root)
expect(direct).toEqual({
ok: false,
reason: 'image path is outside the active session work directory',
})
expect(symlink).toEqual(direct)
})
it('rejects missing paths and directories', async () => {
const root = makeTempRoot('outbound-root-')
const directory = path.join(root, 'directory.png')
fs.mkdirSync(directory)
const missing = await loadSafeOutboundImage({
id: 'missing',
source: { kind: 'path', path: path.join(root, 'missing.png'), mime: 'image/png' },
}, root)
const notFile = await loadSafeOutboundImage({
id: 'directory',
source: { kind: 'path', path: directory, mime: 'image/png' },
}, root)
expect(missing).toEqual({ ok: false, reason: 'image path does not exist' })
expect(notFile).toEqual({ ok: false, reason: 'image path is not a regular file' })
})
it('never fetches remote URLs from Agent-authored markdown', async () => {
const root = makeTempRoot('outbound-root-')
const result = await loadSafeOutboundImage({
id: 'url',
source: { kind: 'url', url: 'http://127.0.0.1:3456/private' },
}, root)
expect(result).toEqual({
ok: false,
reason: 'remote image URLs are not fetched from Agent output',
})
})
it('rejects oversized local files before buffering them', async () => {
const root = makeTempRoot('outbound-root-')
const imagePath = path.join(root, 'large.png')
fs.writeFileSync(imagePath, Buffer.alloc(IMAGE_MAX_BYTES + 1))
const result = await loadSafeOutboundImage({
id: 'large',
source: { kind: 'path', path: imagePath, mime: 'image/png' },
}, root)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.reason).toContain('图片过大')
})
it('preflights base64 size and MIME before decoding', async () => {
const root = makeTempRoot('outbound-root-')
const allowed = await loadSafeOutboundImage({
id: 'base64',
source: { kind: 'base64', data: 'cG5n', mime: 'image/png' },
}, root)
const unsupported = await loadSafeOutboundImage({
id: 'svg',
source: { kind: 'base64', data: 'AAAA', mime: 'image/svg+xml' },
}, root)
const oversized = await loadSafeOutboundImage({
id: 'large-base64',
source: {
kind: 'base64',
data: 'A'.repeat(Math.ceil((IMAGE_MAX_BYTES + 1) * 4 / 3)),
mime: 'image/png',
},
}, root)
expect(allowed.ok).toBe(true)
if (allowed.ok) expect(allowed.buffer.toString()).toBe('png')
expect(unsupported.ok).toBe(false)
expect(oversized.ok).toBe(false)
})
it('only calls the platform sender after the image passes validation', async () => {
const root = makeTempRoot('outbound-root-')
const imagePath = path.join(root, 'result.png')
fs.writeFileSync(imagePath, Buffer.from('png'))
const sent: string[] = []
const allowed = await sendSafeOutboundImage({
id: 'inside',
source: { kind: 'path', path: imagePath, mime: 'image/png' },
}, root, async (buffer, mime) => {
sent.push(`${buffer.toString()}:${mime}`)
})
const blocked = await sendSafeOutboundImage({
id: 'url',
source: { kind: 'url', url: 'http://127.0.0.1/private' },
}, root, async () => {
sent.push('unsafe')
})
expect(allowed.ok).toBe(true)
expect(blocked.ok).toBe(false)
expect(sent).toEqual(['png:image/png'])
})
})
@@ -0,0 +1,87 @@
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { IMAGE_MAX_BYTES, checkAttachmentLimit } from './attachment-limits.js'
import type { PendingUpload } from './attachment-types.js'
export type SafeOutboundImage =
| { ok: true; buffer: Buffer; mime: string }
| { ok: false; reason: string }
export async function loadSafeOutboundImage(
pending: PendingUpload,
sessionWorkDir: string | null | undefined,
): Promise<SafeOutboundImage> {
if (!sessionWorkDir) {
return { ok: false, reason: 'missing session work directory' }
}
if (pending.source.kind === 'url') {
return { ok: false, reason: 'remote image URLs are not fetched from Agent output' }
}
if (pending.source.kind === 'base64') {
const mime = pending.source.mime
const estimatedBytes = Math.ceil(pending.source.data.length * 3 / 4)
const preflight = checkAttachmentLimit('image', estimatedBytes, mime)
if (!preflight.ok) return { ok: false, reason: preflight.hint }
const buffer = Buffer.from(pending.source.data, 'base64')
const exact = checkAttachmentLimit('image', buffer.length, mime)
return exact.ok
? { ok: true, buffer, mime }
: { ok: false, reason: exact.hint }
}
let canonicalRoot: string
let canonicalFile: string
try {
canonicalRoot = await fs.realpath(sessionWorkDir)
canonicalFile = await fs.realpath(pending.source.path)
} catch {
return { ok: false, reason: 'image path does not exist' }
}
const relative = path.relative(canonicalRoot, canonicalFile)
if (relative.startsWith('..') || path.isAbsolute(relative)) {
return { ok: false, reason: 'image path is outside the active session work directory' }
}
const mime = pending.source.mime ?? 'image/png'
const file = await fs.open(canonicalFile, 'r')
try {
const stat = await file.stat()
if (!stat.isFile()) {
return { ok: false, reason: 'image path is not a regular file' }
}
const preflight = checkAttachmentLimit('image', stat.size, mime)
if (!preflight.ok) return { ok: false, reason: preflight.hint }
const chunks: Buffer[] = []
let total = 0
const stream = file.createReadStream({ autoClose: false })
for await (const chunk of stream) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
total += buffer.length
if (total > IMAGE_MAX_BYTES) {
stream.destroy()
return { ok: false, reason: 'image exceeded the 10 MB limit while reading' }
}
chunks.push(buffer)
}
return { ok: true, buffer: Buffer.concat(chunks, total), mime }
} finally {
await file.close()
}
}
export async function sendSafeOutboundImage(
pending: PendingUpload,
sessionWorkDir: string | null | undefined,
send: (buffer: Buffer, mime: string) => Promise<unknown>,
): Promise<SafeOutboundImage> {
const loaded = await loadSafeOutboundImage(pending, sessionWorkDir)
if (!loaded.ok) return loaded
await send(loaded.buffer, loaded.mime)
return loaded
}
+57 -10
View File
@@ -112,12 +112,20 @@ export class AdapterHttpClient {
}
async createSession(workDir: string): Promise<string> {
const allowedWorkDir = this.resolveAllowedProjectPath(workDir)
if (!allowedWorkDir) {
throw new Error('Failed to create session: workDir is outside the configured project roots')
}
const { controller, timer } = this.createTimeoutController()
try {
const res = await this.request('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workDir }),
body: JSON.stringify({
workDir: allowedWorkDir,
permissionMode: 'default',
}),
signal: controller.signal,
})
if (!res.ok) {
@@ -142,7 +150,13 @@ export class AdapterHttpClient {
const err = await res.json().catch(() => ({ message: res.statusText }))
throw new Error(`Failed to check session: ${(err as any).message}`)
}
return true
const data = (await res.json()) as {
status?: {
workDir?: string
permissionMode?: string
}
}
return this.isSafeRemoteSession(data.status)
} finally {
clearTimeout(timer)
}
@@ -158,7 +172,9 @@ export class AdapterHttpClient {
throw new Error(`Failed to list projects: ${res.statusText}`)
}
const data = (await res.json()) as { projects: RecentProject[] }
return data.projects
return data.projects.filter((project) =>
this.resolveAllowedProjectPath(project.realPath || project.projectPath),
)
} finally {
clearTimeout(timer)
}
@@ -169,11 +185,10 @@ export class AdapterHttpClient {
* Returns { project, ambiguous[] } — ambiguous is set when multiple projects match.
*/
async matchProject(query: string): Promise<{ project?: RecentProject; ambiguous?: RecentProject[] }> {
const directPath = resolveExistingProjectPath(query)
if (directPath) {
if (!isPathWithinAllowedRoots(directPath, this.allowedProjectRoots)) {
return {}
}
const resolvedDirectPath = resolveExistingProjectPath(query)
if (resolvedDirectPath) {
const directPath = this.resolveAllowedProjectPath(resolvedDirectPath)
if (!directPath) return {}
return {
project: {
@@ -253,6 +268,10 @@ export class AdapterHttpClient {
limit?: number
offset?: number
}): Promise<{ sessions: SessionListItem[]; total: number }> {
if (options?.project && !this.resolveAllowedProjectPath(options.project)) {
return { sessions: [], total: 0 }
}
const params = new URLSearchParams()
if (options?.project) params.set('project', options.project)
if (options?.limit !== undefined) params.set('limit', String(options.limit))
@@ -268,7 +287,12 @@ export class AdapterHttpClient {
const err = await res.json().catch(() => ({ message: res.statusText }))
throw new Error(`Failed to list sessions: ${(err as any).message}`)
}
return (await res.json()) as { sessions: SessionListItem[]; total: number }
const data = (await res.json()) as { sessions: SessionListItem[]; total: number }
const sessions = data.sessions.filter((session) => this.isSafeRemoteSession({
workDir: session.workDir,
permissionMode: session.permissionMode,
}))
return { sessions, total: sessions.length }
} finally {
clearTimeout(timer)
}
@@ -335,7 +359,12 @@ export class AdapterHttpClient {
}
async listSkills(cwd: string): Promise<{ skills: SkillSummary[] }> {
const params = new URLSearchParams({ cwd })
const allowedCwd = this.resolveAllowedProjectPath(cwd)
if (!allowedCwd) {
throw new Error('Failed to list skills: cwd is outside the configured project roots')
}
const params = new URLSearchParams({ cwd: allowedCwd })
const { controller, timer } = this.createTimeoutController()
try {
const res = await this.request(`/api/skills?${params.toString()}`, {
@@ -384,6 +413,24 @@ export class AdapterHttpClient {
clearTimeout(timer)
}
}
private resolveAllowedProjectPath(value: string | null | undefined): string | null {
if (!value) return null
const resolved = resolveExistingProjectPath(value)
if (!resolved || !isPathWithinAllowedRoots(resolved, this.allowedProjectRoots)) {
return null
}
return resolved
}
private isSafeRemoteSession(
status: { workDir?: string | null; permissionMode?: string } | undefined,
): boolean {
if (!status?.workDir || status.permissionMode === 'bypassPermissions') {
return false
}
return Boolean(this.resolveAllowedProjectPath(status.workDir))
}
}
function isPathWithinAllowedRoots(target: string, roots: string[]): boolean {
+158 -4
View File
@@ -8,8 +8,11 @@ import {
registerAuthorizedTelegramCommand,
registerTelegramExtendedCommands,
renderSelectionView,
resolveTelegramPermissionCallback,
sessionToSelectionItem,
shouldProcessTelegramMessage,
skillToSelectionItem,
telegramMessageDedupKey,
tryHandleTelegramSelectionCallback,
} from '../commands.js'
@@ -47,6 +50,7 @@ function createCommandContext(options?: {
function createController(overrides?: Record<string, unknown>) {
const sent: Array<{ chatId: number; text: string; options?: unknown }> = []
const sentUserMessages: Array<{ chatId: string; content: string }> = []
const runtimeModels: string[] = []
const bridgeEvents: string[] = []
const deps = {
@@ -137,6 +141,10 @@ function createController(overrides?: Record<string, unknown>) {
}),
onBridgeServerMessage: mock((chatId: string) => bridgeEvents.push(`listen:${chatId}`)),
waitForBridgeOpen: mock(async () => true),
sendUserMessage: mock((chatId: string, content: string) => {
sentUserMessages.push({ chatId, content })
return true
}),
setRuntimeModel: mock((_chatId: string, modelId: string) => {
runtimeModels.push(modelId)
}),
@@ -147,6 +155,7 @@ function createController(overrides?: Record<string, unknown>) {
controller: createTelegramCommandController(deps),
deps,
sent,
sentUserMessages,
runtimeModels,
bridgeEvents,
}
@@ -264,6 +273,66 @@ describe('Telegram command controller helpers', () => {
expect(denied.replies[0]).toContain('未授权')
})
it('only resolves pending permission callbacks from authorized users', () => {
const sendPermissionResponse = mock(() => true)
const pendingRequestIds = new Set(['req-1'])
const base = {
chatId: '42',
decision: { requestId: 'req-1', allowed: true },
pendingRequestIds,
sendPermissionResponse,
}
expect(resolveTelegramPermissionCallback({
...base,
userId: 999,
isAllowedUser: () => false,
})).toBe('unauthorized')
expect(sendPermissionResponse).not.toHaveBeenCalled()
expect(resolveTelegramPermissionCallback({
...base,
userId: 7,
decision: { requestId: 'expired', allowed: true },
isAllowedUser: () => true,
})).toBe('not_pending')
expect(sendPermissionResponse).not.toHaveBeenCalled()
const disconnectedPending = new Set(['req-2'])
expect(resolveTelegramPermissionCallback({
...base,
userId: 7,
decision: { requestId: 'req-2', allowed: false },
pendingRequestIds: disconnectedPending,
isAllowedUser: () => true,
sendPermissionResponse: () => false,
})).toBe('send_failed')
expect(disconnectedPending.has('req-2')).toBe(true)
expect(resolveTelegramPermissionCallback({
...base,
userId: 7,
isAllowedUser: () => true,
})).toBe('sent')
expect(sendPermissionResponse).toHaveBeenCalledWith('42', 'req-1', true, undefined)
expect(pendingRequestIds.has('req-1')).toBe(false)
})
it('scopes duplicate message keys to the Telegram chat', () => {
expect(telegramMessageDedupKey('42', 7)).toBe('telegram:42:7')
expect(telegramMessageDedupKey('43', 7)).not.toBe(telegramMessageDedupKey('42', 7))
const keys: string[] = []
const dedup = {
tryRecord: (key: string) => {
keys.push(key)
return true
},
}
expect(shouldProcessTelegramMessage(dedup, '42', undefined)).toBe(false)
expect(shouldProcessTelegramMessage(dedup, '42', 7)).toBe(true)
expect(keys).toEqual(['telegram:42:7'])
})
it('syncs official provider command and rejects unauthorized private chats', async () => {
const { controller, deps, runtimeModels } = createController()
const allowed = createCommandContext({ match: 'claude' })
@@ -421,8 +490,8 @@ describe('Telegram command controller helpers', () => {
expect(callback.edits[0]).toContain('已切换模型')
})
it('lists invocable skills and shows selected skill details', async () => {
const { controller, deps, sent } = createController()
it('lists invocable skills and sends the selected skill into the active agent session', async () => {
const { controller, deps, sent, sentUserMessages } = createController()
await controller.handleSkillsCommand(createCommandContext().ctx)
expect(deps.httpClient.listSkills).toHaveBeenCalledWith('/work/repo')
@@ -434,7 +503,43 @@ describe('Telegram command controller helpers', () => {
action: 'pick',
index: 0,
})
expect(callback.edits[0]).toContain('Skill:Skill A')
expect(deps.ensureExistingSession).toHaveBeenCalledWith('42')
expect(sentUserMessages).toEqual([{ chatId: '42', content: '/skill-a' }])
expect(callback.edits[0]).toContain('已调用 Skill:Skill A')
})
it('does not claim a selected skill ran when the agent session is unavailable', async () => {
const unavailable = createController({
ensureExistingSession: mock(async () => null),
})
await unavailable.controller.handleSkillsCommand(createCommandContext().ctx)
expect(unavailable.sent.at(-1)?.text).toContain('当前项目可用 Skills')
const callback = createCommandContext()
await unavailable.controller.handleSelectionCallback(callback.ctx, {
kind: 'skill',
action: 'pick',
index: 0,
})
expect(unavailable.sentUserMessages).toEqual([])
expect(callback.edits[0]).toContain('会话已失效')
})
it('reports a disconnected bridge instead of dropping a selected skill', async () => {
const disconnected = createController({
sendUserMessage: mock(() => false),
})
await disconnected.controller.handleSkillsCommand(createCommandContext().ctx)
const callback = createCommandContext()
await disconnected.controller.handleSelectionCallback(callback.ctx, {
kind: 'skill',
action: 'pick',
index: 0,
})
expect(callback.edits[0]).toContain('发送失败')
})
it('resumes a historical project session through two callbacks', async () => {
@@ -585,6 +690,8 @@ describe('Telegram command controller helpers', () => {
it('creates a controller from runtime dependencies', async () => {
const events: string[] = []
let allowPermissionUser = true
let sendPermissionSucceeds = true
const controller = createTelegramRuntimeCommandController({
botApi: { sendMessage: mock(async () => {}) },
httpClient: createController().deps.httpClient,
@@ -597,12 +704,20 @@ describe('Telegram command controller helpers', () => {
void handler({ type: 'connected' })
},
waitForOpen: mock(async () => true),
sendUserMessage: (chatId, content) => {
events.push(`send:${chatId}:${content}`)
return true
},
sendPermissionResponse: (chatId, requestId, allowed, rule) => {
events.push(`permit:${chatId}:${requestId}:${allowed}:${rule ?? ''}`)
return sendPermissionSucceeds
},
},
sessionStore: {
set: (chatId, sessionId, workDir) => events.push(`store:${chatId}:${sessionId}:${workDir}`),
delete: (chatId) => events.push(`delete:${chatId}`),
},
isAllowedUser: () => true,
isAllowedUser: () => allowPermissionUser,
ensureExistingSession: mock(async () => ({ sessionId: 'active', workDir: '/work/repo' })),
clearTransientChatState: (chatId) => events.push(`clear:${chatId}`),
handleServerMessage: (chatId, msg) => {
@@ -623,8 +738,47 @@ describe('Telegram command controller helpers', () => {
action: 'pick',
index: 0,
})
const permissionCtx = createCommandContext()
await controller.handlePermissionCallback(permissionCtx.ctx, {
requestId: 'req-1',
allowed: true,
}, new Map([['42', new Set(['req-1'])]]), (chatId) => events.push(`decrement:${chatId}`))
expect(events).toContain('model:42:model-x')
expect(events).toContain('message:42:connected')
expect(events).toContain('permit:42:req-1:true:')
expect(events).toContain('decrement:42')
expect(permissionCtx.edits[0]).toContain('已允许')
const missingIdentityCtx = createCommandContext()
delete (missingIdentityCtx.ctx as any).from
await expect(controller.handlePermissionCallback(missingIdentityCtx.ctx, {
requestId: 'missing-user',
allowed: true,
}, new Map(), () => {})).resolves.toBe('unauthorized')
allowPermissionUser = false
const unauthorizedCtx = createCommandContext()
await expect(controller.handlePermissionCallback(unauthorizedCtx.ctx, {
requestId: 'unauthorized',
allowed: true,
}, new Map([['42', new Set(['unauthorized'])]]), () => {})).resolves.toBe('unauthorized')
expect(unauthorizedCtx.answers).toContain('未授权')
allowPermissionUser = true
const expiredCtx = createCommandContext()
await expect(controller.handlePermissionCallback(expiredCtx.ctx, {
requestId: 'expired',
allowed: true,
}, new Map(), () => {})).resolves.toBe('not_pending')
expect(expiredCtx.answers).toContain('权限请求已失效')
sendPermissionSucceeds = false
const failedCtx = createCommandContext()
await expect(controller.handlePermissionCallback(failedCtx.ctx, {
requestId: 'send-failed',
allowed: false,
}, new Map([['42', new Set(['send-failed'])]]), () => {})).resolves.toBe('send_failed')
expect(failedCtx.answers).toContain('权限响应发送失败')
})
})
@@ -180,22 +180,4 @@ describe('Telegram message formatting', () => {
})
})
describe('whitelist logic', () => {
it('empty allowedUsers means allow all', () => {
const allowedUsers: number[] = []
const isAllowed = (userId: number) =>
allowedUsers.length === 0 || allowedUsers.includes(userId)
expect(isAllowed(12345)).toBe(true)
expect(isAllowed(99999)).toBe(true)
})
it('non-empty allowedUsers filters correctly', () => {
const allowedUsers = [111, 222]
const isAllowed = (userId: number) =>
allowedUsers.length === 0 || allowedUsers.includes(userId)
expect(isAllowed(111)).toBe(true)
expect(isAllowed(222)).toBe(true)
expect(isAllowed(333)).toBe(false)
})
})
})
+149 -4
View File
@@ -1,4 +1,8 @@
import { formatImHelp } from '../common/format.js'
import {
formatPermissionDecisionStatus,
type PermissionDecision,
} from '../common/permission.js'
import type {
AdapterHttpClient,
ProviderSummary,
@@ -71,10 +75,37 @@ export type TelegramCommandControllerDeps = {
connectBridgeSession: (chatId: string, sessionId: string) => void
onBridgeServerMessage: (chatId: string) => void
waitForBridgeOpen: (chatId: string) => Promise<boolean>
sendUserMessage: (chatId: string, content: string) => boolean
setRuntimeModel: RuntimeModelSetter
}
export type TelegramCommandController = ReturnType<typeof createTelegramCommandController>
export type TelegramRuntimeCommandController = TelegramCommandController & {
handlePermissionCallback: (
ctx: TelegramCommandContext,
decision: PermissionDecision,
pendingPermissions: Map<string, Set<string>>,
onResolved: (chatId: string) => void,
) => Promise<TelegramPermissionCallbackResult>
}
export type TelegramPermissionCallbackResult =
| 'sent'
| 'unauthorized'
| 'not_pending'
| 'send_failed'
export function telegramMessageDedupKey(chatId: string, messageId: number): string {
return `telegram:${chatId}:${messageId}`
}
export function shouldProcessTelegramMessage(
dedup: { tryRecord: (key: string) => boolean },
chatId: string,
messageId: number | undefined,
): boolean {
return messageId !== undefined &&
dedup.tryRecord(telegramMessageDedupKey(chatId, messageId))
}
export type TelegramCommandRegistrar = {
command: (command: string, handler: (ctx: TelegramCommandContext) => unknown) => unknown
@@ -102,6 +133,83 @@ export function registerAuthorizedTelegramCommand(
})())
}
export function resolveTelegramPermissionCallback(params: {
chatId: string
userId: number
decision: PermissionDecision
pendingRequestIds?: Set<string>
isAllowedUser: (userId: number) => boolean
sendPermissionResponse: (
chatId: string,
requestId: string,
allowed: boolean,
rule?: string,
) => boolean
}): TelegramPermissionCallbackResult {
if (!params.isAllowedUser(params.userId)) return 'unauthorized'
if (!params.pendingRequestIds?.has(params.decision.requestId)) return 'not_pending'
const sent = params.sendPermissionResponse(
params.chatId,
params.decision.requestId,
params.decision.allowed,
params.decision.rule,
)
if (!sent) return 'send_failed'
params.pendingRequestIds.delete(params.decision.requestId)
return 'sent'
}
async function handleTelegramPermissionCallback(
ctx: TelegramCommandContext,
decision: PermissionDecision,
deps: Pick<TelegramRuntimeCommandControllerDeps, 'isAllowedUser'> & {
pendingPermissions: Map<string, Set<string>>
sendPermissionResponse: (
chatId: string,
requestId: string,
allowed: boolean,
rule?: string,
) => boolean
onResolved: (chatId: string) => void
},
): Promise<TelegramPermissionCallbackResult> {
const callbackChatId = ctx.callbackQuery?.message?.chat.id
const callbackUserId = ctx.from?.id
if (callbackChatId === undefined || callbackUserId === undefined) {
await ctx.answerCallbackQuery('未授权').catch(() => {})
return 'unauthorized'
}
const chatId = String(callbackChatId)
const result = resolveTelegramPermissionCallback({
chatId,
userId: callbackUserId,
decision,
pendingRequestIds: deps.pendingPermissions.get(chatId),
isAllowedUser: deps.isAllowedUser,
sendPermissionResponse: deps.sendPermissionResponse,
})
if (result !== 'sent') {
const message = result === 'unauthorized'
? '未授权'
: result === 'not_pending'
? '权限请求已失效'
: '权限响应发送失败'
await ctx.answerCallbackQuery(message).catch(() => {})
return result
}
deps.onResolved(chatId)
const statusText = formatPermissionDecisionStatus(decision)
await ctx.editMessageText(
`${ctx.callbackQuery?.message?.text ?? ''}\n\n${statusText}`,
).catch(() => {})
await ctx.answerCallbackQuery(statusText)
return 'sent'
}
export type TelegramRuntimeCommandControllerDeps = {
botApi: TelegramSendApi
httpClient: AdapterHttpClient
@@ -111,6 +219,13 @@ export type TelegramRuntimeCommandControllerDeps = {
connectSession: (chatId: string, sessionId: string) => void
onServerMessage: (chatId: string, handler: (msg: unknown) => void | Promise<void>) => void
waitForOpen: (chatId: string) => Promise<boolean>
sendUserMessage: (chatId: string, content: string) => boolean
sendPermissionResponse: (
chatId: string,
requestId: string,
allowed: boolean,
rule?: string,
) => boolean
}
sessionStore: {
set: (chatId: string, sessionId: string, workDir: string) => void
@@ -125,8 +240,8 @@ export type TelegramRuntimeCommandControllerDeps = {
export function createTelegramRuntimeCommandController(
deps: TelegramRuntimeCommandControllerDeps,
): TelegramCommandController {
return createTelegramCommandController({
): TelegramRuntimeCommandController {
const controller = createTelegramCommandController({
api: deps.botApi,
httpClient: deps.httpClient,
defaultWorkDir: deps.defaultWorkDir,
@@ -142,8 +257,23 @@ export function createTelegramRuntimeCommandController(
(msg) => deps.handleServerMessage(chatId, msg),
),
waitForBridgeOpen: (chatId) => deps.bridge.waitForOpen(chatId),
sendUserMessage: (chatId, content) => deps.bridge.sendUserMessage(chatId, content),
setRuntimeModel: deps.setRuntimeModel,
})
return {
...controller,
handlePermissionCallback: (ctx, decision, pendingPermissions, onResolved) => handleTelegramPermissionCallback(
ctx,
decision,
{
isAllowedUser: deps.isAllowedUser,
pendingPermissions,
sendPermissionResponse: (chatId, requestId, allowed, rule) =>
deps.bridge.sendPermissionResponse(chatId, requestId, allowed, rule),
onResolved,
},
),
}
}
export function registerTelegramExtendedCommands(
@@ -374,12 +504,27 @@ export function createTelegramCommandController(deps: TelegramCommandControllerD
): Promise<void> => {
const chatId = getCallbackChatId(ctx)
if (!chatId) return
const stored = await deps.ensureExistingSession(chatId)
if (!stored) {
pendingSelections.delete(chatId)
await ctx.editMessageText('⚠️ 会话已失效,请发送 /new 重新选择项目后再调用 Skill。')
return
}
const invocation = `/${item.value}`
if (!deps.sendUserMessage(chatId, invocation)) {
await ctx.editMessageText('⚠️ Skill 发送失败,连接可能已断开。请发送 /new 重新连接会话。')
return
}
pendingSelections.delete(chatId)
await ctx.editMessageText([
`Skill:${item.label}`,
`✅ 已调用 Skill:${item.label}`,
invocation,
item.description,
'',
'可以直接描述任务让 Agent 自动选择,也可以在桌面端 /skills 查看详情。',
'任务已发送给当前 Agent,会继续使用这条会话的上下文、工具和权限设置。',
].filter(Boolean).join('\n'))
}
+8 -49
View File
@@ -40,9 +40,9 @@ import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js'
import type { AttachmentRef } from '../common/ws-bridge.js'
import { ImageBlockWatcher } from '../common/attachment/image-block-watcher.js'
import type { PendingUpload } from '../common/attachment/attachment-types.js'
import * as fs from 'node:fs/promises'
import { sendSafeOutboundImage } from '../common/attachment/outbound-image.js'
import { syncTelegramBotCommands } from './menu.js'
import { createTelegramRuntimeCommandController, registerAuthorizedTelegramCommand, registerTelegramExtendedCommands, tryHandleTelegramSelectionCallback } from './commands.js'
import { createTelegramRuntimeCommandController, registerAuthorizedTelegramCommand, registerTelegramExtendedCommands, shouldProcessTelegramMessage, tryHandleTelegramSelectionCallback } from './commands.js'
const TELEGRAM_TEXT_LIMIT = 4000 // leave margin below 4096
const TELEGRAM_STREAMING_TEXT_LIMIT = TELEGRAM_TEXT_LIMIT - 2 // reserve room for cursor
@@ -352,35 +352,8 @@ async function showProjectPicker(chatId: string): Promise<void> {
async function dispatchOutboundMedia(chatId: string, pending: PendingUpload): Promise<void> {
const numericChatId = Number(chatId)
try {
let buffer: Buffer
let mime = 'image/png'
switch (pending.source.kind) {
case 'base64': {
buffer = Buffer.from(pending.source.data, 'base64')
mime = pending.source.mime
break
}
case 'path': {
buffer = await fs.readFile(pending.source.path)
mime = pending.source.mime ?? 'image/png'
break
}
case 'url': {
const resp = await fetch(pending.source.url)
if (!resp.ok) {
throw new Error(`fetch ${pending.source.url} -> ${resp.status}`)
}
buffer = Buffer.from(await resp.arrayBuffer())
mime = pending.source.mime ?? resp.headers.get('content-type') ?? 'image/png'
break
}
}
const check = checkAttachmentLimit('image', buffer.length, mime)
if (!check.ok) {
console.warn('[Telegram] Outbound image rejected:', check.hint)
return
}
await media.sendPhoto(numericChatId, buffer, pending.alt)
const loaded = await sendSafeOutboundImage(pending, sessionStore.get(chatId)?.workDir, (buffer) => media.sendPhoto(numericChatId, buffer, pending.alt))
if (!loaded.ok) console.warn('[Telegram] Outbound image rejected:', loaded.reason)
} catch (err) {
console.error(
'[Telegram] dispatchOutboundMedia failed:',
@@ -677,9 +650,9 @@ async function routeUserMessage(
attachments: AttachmentRef[],
): Promise<void> {
if (!ctx.from || ctx.chat?.type !== 'private') return
if (!dedup.tryRecord(String(ctx.message?.message_id))) return
const chatId = String(ctx.chat.id)
if (!shouldProcessTelegramMessage(dedup, chatId, ctx.message?.message_id)) return
const userId = ctx.from.id
if (!isAllowedUser('telegram', userId)) {
@@ -813,28 +786,14 @@ bot.on('callback_query:data', async (ctx) => {
const decision = parsePermitCallbackData(data)
if (!decision) return
const chatId = String(ctx.callbackQuery.message?.chat.id)
bridge.sendPermissionResponse(chatId, decision.requestId, decision.allowed, decision.rule)
const runtime = getRuntimeState(chatId)
runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1)
pendingPermissions.get(chatId)?.delete(decision.requestId)
const statusText = formatPermissionDecisionStatus(decision)
try {
await ctx.editMessageText(
ctx.callbackQuery.message?.text + `\n\n${statusText}`,
)
} catch { /* ignore */ }
await ctx.answerCallbackQuery(statusText)
await commandController.handlePermissionCallback(ctx, decision, pendingPermissions, (chatId) => getRuntimeState(chatId).pendingPermissionCount = Math.max(0, getRuntimeState(chatId).pendingPermissionCount - 1))
})
// ---------- start ----------
console.log('[Telegram] Starting bot...')
console.log(`[Telegram] Server: ${config.serverUrl}`)
console.log(`[Telegram] Allowed users: ${config.telegram.allowedUsers.length === 0 ? 'all' : config.telegram.allowedUsers.join(', ')}`)
console.log(`[Telegram] Allowed users: ${config.telegram.allowedUsers.length === 0 ? 'paired users only' : config.telegram.allowedUsers.join(', ')}`)
void syncTelegramBotCommands(bot.api).then(() => console.log('[Telegram] Command menu synced')).catch((err) => console.warn('[Telegram] Command menu sync failed:', err instanceof Error ? err.message : err))
+2 -2
View File
@@ -53,9 +53,9 @@ export const TELEGRAM_BOT_COMMANDS: TelegramBotCommand[] = [
{ command: 'stop', description: '停止当前生成' },
{ command: 'provider', description: '切换 Provider' },
{ command: 'model', description: '切换模型' },
{ command: 'skills', description: '查看 Skills' },
{ command: 'skills', description: '查看并调用 Skills' },
{ command: 'allow', description: '允许权限请求' },
{ command: 'always', description: '永久允许权限请求' },
{ command: 'always', description: '本会话永久允许' },
{ command: 'deny', description: '拒绝权限请求' },
]