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

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 })
}
})
})

View File

@ -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'])
})
})

View File

@ -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
}

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 {

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('SkillSkill A')
expect(deps.ensureExistingSession).toHaveBeenCalledWith('42')
expect(sentUserMessages).toEqual([{ chatId: '42', content: '/skill-a' }])
expect(callback.edits[0]).toContain('已调用 SkillSkill 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('权限响应发送失败')
})
})

View File

@ -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)
})
})
})

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'))
}

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))

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: '拒绝权限请求' },
]

View File

@ -40,14 +40,26 @@ Codes are valid for 60 minutes, work once, and are invalidated when a new one is
- `/start` — show help and available commands
- `/help` — show available commands
- `/projects` — list recent projects and switch
- `/resume` — choose and restore a previous session
- `/status` — project, model, run state, and task summary
- `/new` — clear the current binding and choose a project again
- `/clear` — clear context, keep the project binding
- `/stop` — stop the current generation
- `/provider` — view or switch the provider
- `/model [model]` — view or switch the model
- `/skills` — list project Skills and invoke one by selecting it
## Agent capabilities and boundaries
Telegram is not a separate question-and-answer model. Regular messages and Skills selected from `/skills` enter the same Claude Code Agent session for the current project, so they retain multi-turn context and can use the file, terminal, Git, Skill, and MCP tools already available to that session. Selecting a Skill sends its `/<skill-name>` invocation into the Agent, where the existing Skill system loads `SKILL.md` and continues the task.
These capabilities are intended for one trusted user remotely controlling their own machine. A paired account receives the full Agent capabilities available in the selected project. Permission prompts are an operation gate, not an operating-system sandbox. Do not expose the Bot to public groups or untrusted accounts, and do not install unreviewed Skills, Plugins, or MCP servers from chat.
The adapter accepts private chats only from paired or allowlisted accounts. Project lists, name matching, and historical session restore are restricted to the configured project root. New remote sessions always use the `default` permission mode, and historical `bypassPermissions` sessions are not restored remotely. Local images in Agent output must resolve inside the active session work directory; the Adapter never fetches remote image URLs from Agent-authored text.
## Approval and reply behavior
A permission request arrives as a message with three buttons: allow once, always allow the matching operation, and deny. The choice is converted to a `permission_response` for the pending Desktop session.
A permission request arrives as a message with three buttons: allow once, always allow the matching operation for this session, and deny. Only a currently pending request can be confirmed, and the choice is converted to a `permission_response` for that same Desktop session.
Replies pass through a streaming buffer: a placeholder can be sent while Claude is thinking, text deltas accumulate in place, and completed text is split into platform-sized messages.

View File

@ -55,14 +55,26 @@ order: 2
- `/start` — 显示帮助和可用命令
- `/help` — 显示当前可用命令
- `/projects` — 列出最近项目并切换
- `/resume` — 选择并恢复历史会话
- `/status` — 当前项目、模型、运行状态和任务摘要
- `/new` — 清空当前绑定并重新选择项目
- `/clear` — 清空上下文,保留项目绑定
- `/stop` — 停止本轮生成
- `/provider` — 查看或切换 Provider
- `/model [model]` — 查看或切换模型
- `/skills` — 查看当前项目可用的 Skills点选后直接调用
## Agent 能力与边界
Telegram 不是一套独立的问答模型。普通消息和从 `/skills` 点选的 Skill 都会进入当前项目的同一条 Claude Code Agent 会话因此会延续多轮上下文并能使用该会话已经加载的文件、终端、Git、Skills 和 MCP 工具。点选 Skill 后adapter 会把对应的 `/<skill-name>` 作为用户消息送入 Agent由现有 Skill 系统加载 `SKILL.md` 并继续执行。
这些能力只适合单一可信用户远程控制自己的机器。配对账号获得的是当前项目中的完整 Agent 能力,权限确认只是操作闸门,并不是操作系统沙箱;不要把 Bot 暴露给公开群聊或不可信账号,也不要从聊天中安装未经本机审核的 Skill、Plugin 或 MCP。
Adapter 只接受已配对或在允许列表中的私聊账号。项目列表、名称匹配和历史会话恢复都会限制在配置的项目根目录内;新建远程会话固定使用 `default` 权限模式,`bypassPermissions` 历史会话不会在远程恢复。Agent 输出中的本地图片只能从当前会话工作目录读取,远程图片 URL 不会由 Adapter 自动请求。
## 权限审批与消息表现
Claude 请求敏感权限时Telegram 里会收到一条带按钮的消息,三个选项分别是允许一次、永久允许同类操作、拒绝。点完结果直接回传给桌面端会话。
Claude 请求敏感权限时Telegram 里会收到一条带按钮的消息,三个选项分别是允许一次、本次会话内永久允许同类操作、拒绝。只有当前待处理的请求才能被确认,点完结果直接回传给同一条桌面端会话。
回复走一层流式缓冲:思考阶段先发占位消息,正文逐步累积更新,完成后按 Telegram 的长度上限分片发送。