feat: add secure remote channel media

This commit is contained in:
lofyer
2026-08-09 20:33:35 +08:00
parent 6c891f3522
commit 66b098ae36
46 changed files with 4403 additions and 1594 deletions
+2 -1
View File
@@ -33,7 +33,8 @@ export const projectCreateSchema = z
name: z.string().trim().min(1).max(120),
description: z.string().trim().max(2_000),
rootPath: z.string().trim().max(4_096),
defaultWorkMode: workModeSchema
defaultWorkMode: workModeSchema,
runtimeSelection: agentRuntimeSelectionSchema.optional()
})
.strict()
+105 -4
View File
@@ -7,7 +7,11 @@ export const CHANNEL_LIMITS = {
maximumTextLength: 32_000,
maximumResultLength: 16_000,
maximumErrorLength: 1_000,
maximumStatusLength: 64
maximumStatusLength: 64,
maximumAttachmentCount: 4,
maximumAttachmentBytes: 12 * 1024 * 1024,
maximumAttachmentNameLength: 240,
maximumAttachmentMimeTypeLength: 128
} as const
const channelIdentifierSchema = z
@@ -19,6 +23,81 @@ const channelIdentifierSchema = z
export const channelWorkModeSchema = z.enum(['ask', 'plan'])
export type ChannelWorkMode = z.infer<typeof channelWorkModeSchema>
const attachmentBase64Schema = z
.string()
.max(
Math.ceil(CHANNEL_LIMITS.maximumAttachmentBytes / 3) * 4 + 4
)
.regex(/^(?:[a-z0-9+/]{4})*(?:[a-z0-9+/]{2}==|[a-z0-9+/]{3}=)?$/iu)
export function decodedBase64Size(value: string): number {
const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0
return (value.length / 4) * 3 - padding
}
export const channelMediaAttachmentSchema = z
.object({
name: z
.string()
.trim()
.min(1)
.max(CHANNEL_LIMITS.maximumAttachmentNameLength),
mimeType: z
.string()
.trim()
.min(1)
.max(CHANNEL_LIMITS.maximumAttachmentMimeTypeLength)
.regex(/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/iu),
size: z
.number()
.int()
.positive()
.max(CHANNEL_LIMITS.maximumAttachmentBytes),
kind: z.enum(['image', 'file']),
dataBase64: attachmentBase64Schema
})
.strict()
.superRefine((attachment, context) => {
const decodedSize = decodedBase64Size(attachment.dataBase64)
if (decodedSize !== attachment.size) {
context.addIssue({
code: 'custom',
path: ['dataBase64'],
message: '附件大小与内容不匹配'
})
}
if (
attachment.kind === 'image' &&
!attachment.mimeType.startsWith('image/')
) {
context.addIssue({
code: 'custom',
path: ['mimeType'],
message: '图片附件类型无效'
})
}
})
export type ChannelMediaAttachment = z.infer<
typeof channelMediaAttachmentSchema
>
export const channelAttachmentsSchema = z
.array(channelMediaAttachmentSchema)
.max(CHANNEL_LIMITS.maximumAttachmentCount)
.superRefine((attachments, context) => {
const total = attachments.reduce(
(sum, attachment) => sum + attachment.size,
0
)
if (total > CHANNEL_LIMITS.maximumAttachmentBytes) {
context.addIssue({
code: 'custom',
message: '附件总大小超过限制'
})
}
})
export const channelInboundTextSchema = z
.object({
channel: z
@@ -35,15 +114,35 @@ export const channelInboundTextSchema = z
conversationId: channelIdentifierSchema,
conversationType: z.enum(['direct', 'group']),
text: z
.string()
.trim()
.max(CHANNEL_LIMITS.maximumTextLength)
.default(''),
attachments: channelAttachmentsSchema.optional(),
attachmentError: z
.string()
.trim()
.min(1)
.max(CHANNEL_LIMITS.maximumTextLength),
.max(CHANNEL_LIMITS.maximumErrorLength)
.optional(),
mentioned: z.boolean().default(false),
workMode: channelWorkModeSchema.default('ask'),
receivedAt: z.number().int().nonnegative().optional()
})
.strict()
.superRefine((message, context) => {
if (
message.text.length === 0 &&
!message.attachments?.length &&
!message.attachmentError
) {
context.addIssue({
code: 'custom',
path: ['text'],
message: '消息内容不能为空'
})
}
})
export type ChannelInboundText = z.infer<
typeof channelInboundTextSchema
@@ -57,7 +156,8 @@ export const channelExecutorResultSchema = z
.min(1)
.max(CHANNEL_LIMITS.maximumStatusLength),
output: z.string().optional(),
error: z.string().optional()
error: z.string().optional(),
attachments: channelAttachmentsSchema.optional()
})
.strict()
@@ -91,7 +191,8 @@ export const channelResultMessageSchema = z
error: z
.string()
.max(CHANNEL_LIMITS.maximumErrorLength)
.optional()
.optional(),
attachments: channelAttachmentsSchema.optional()
})
.strict()
+1 -15
View File
@@ -71,11 +71,7 @@ import type {
EmbeddingSettingsSnapshot
} from './embedding-contracts'
import type { WeixinBindingSnapshot } from './weixin-channel-contracts'
import type {
RemoteChannelActivity,
RemoteChannelApproval,
RemoteChannelApprovalDecision
} from './remote-channel-contracts'
import type { RemoteChannelActivity } from './remote-channel-contracts'
import {
agentRuntimeSelectionSchema,
type AgentRuntimeSelection
@@ -978,16 +974,6 @@ export type DesktopApi = {
onWeixinBindingChanged: (
listener: (snapshot: WeixinBindingSnapshot) => void
) => () => void
respondRemoteApproval: (
approvalId: string,
decision: RemoteChannelApprovalDecision
) => Promise<boolean>
getPendingRemoteApprovals: () => Promise<
RemoteChannelApproval[]
>
onRemoteApproval: (
listener: (approval: RemoteChannelApproval) => void
) => () => void
onRemoteActivity: (
listener: (activity: RemoteChannelActivity) => void
) => () => void
-3
View File
@@ -37,9 +37,6 @@ export const ipcChannels = {
'settings:channels:weixin:binding:disconnect',
weixinBindingChanged:
'settings:channels:weixin:binding:changed',
remoteChannelApprovalRespond: 'channels:remote-approval:respond',
remoteChannelApprovalList: 'channels:remote-approval:list',
remoteChannelApprovalRequested: 'channels:remote-approval:requested',
remoteChannelActivity: 'channels:remote-activity',
applicationSettingsGet: 'settings:application:get',
applicationSettingsUpdate: 'settings:application:update',
+1 -38
View File
@@ -1,48 +1,12 @@
import { z } from 'zod'
import { projectChannelSchema } from './assistant-contracts'
export const remoteChannelApprovalDecisionSchema = z.enum([
'deny',
'once'
])
export type RemoteChannelApprovalDecision = z.infer<
typeof remoteChannelApprovalDecisionSchema
>
export const remoteChannelApprovalSchema = z
.object({
approvalId: z.string().uuid(),
requestId: z.string().uuid(),
kind: z.enum(['request', 'tool']),
channel: projectChannelSchema,
channelLabel: z.string().trim().min(1).max(64),
senderDisplay: z.string().trim().min(1).max(200),
projectName: z.string().trim().min(1).max(120),
rootPath: z.string().max(4_096),
title: z.string().trim().min(1).max(300),
description: z.string().trim().min(1).max(8_000),
toolName: z.string().trim().min(1).max(200).optional(),
argumentSummary: z.string().max(4_000).optional(),
expiresAt: z.string().datetime({ offset: true })
})
.strict()
export type RemoteChannelApproval = z.infer<
typeof remoteChannelApprovalSchema
>
export const remoteChannelApprovalResponseSchema = z
.object({
approvalId: z.string().uuid(),
decision: remoteChannelApprovalDecisionSchema
})
.strict()
export const remoteChannelActivitySchema = z
.object({
requestId: z.string().uuid(),
conversationId: z.string().uuid(),
channel: projectChannelSchema,
kind: z.enum(['request', 'approval', 'tool', 'result']),
kind: z.enum(['request', 'tool', 'result']),
title: z.string().trim().min(1).max(240),
detail: z.string().max(4_000),
status: z.enum([
@@ -50,7 +14,6 @@ export const remoteChannelActivitySchema = z
'running',
'completed',
'failed',
'denied',
'cancelled'
]),
callId: z.string().min(1).max(256).optional()
+51 -1
View File
@@ -32,7 +32,12 @@ export type AgentRuntimeSelection = z.infer<
>
export type RuntimeSelectionRepairSettings = {
modelProfiles: ReadonlyArray<{ id: string }>
modelProfiles: ReadonlyArray<{
id: string
protocol?: string
authentication?: 'api-key' | 'none'
apiKeyConfigured?: boolean
}>
defaultModelProfileId: string
opencodeModelSource:
| { kind: 'platform' }
@@ -42,6 +47,51 @@ export type RuntimeSelectionRepairSettings = {
| { kind: 'profile'; profileId: string }
}
type ChannelModelProfile = RuntimeSelectionRepairSettings['modelProfiles'][number]
export function isChannelModelProfileUsable(
profile: ChannelModelProfile
): boolean {
return (
profile.protocol !== 'openai-images-generations' &&
!(
profile.authentication === 'api-key' &&
profile.apiKeyConfigured === false
)
)
}
export function repairChannelRuntimeSelection(
selection: AgentRuntimeSelection,
settings: RuntimeSelectionRepairSettings
): AgentRuntimeSelection {
const defaultDirectProfile =
settings.modelProfiles.find(
(profile) =>
profile.id === settings.defaultModelProfileId &&
isChannelModelProfileUsable(profile)
) ??
settings.modelProfiles.find(isChannelModelProfileUsable)
const defaultDirectSelection: AgentRuntimeSelection = {
provider: 'model',
profileId:
defaultDirectProfile?.id ?? settings.defaultModelProfileId
}
if (selection.provider === 'auto') {
return defaultDirectSelection
}
const repaired = repairAgentRuntimeSelection(selection, settings)
if (repaired.provider !== 'model') {
return repaired
}
const profile = settings.modelProfiles.find(
(candidate) => candidate.id === repaired.profileId
)
return profile && isChannelModelProfileUsable(profile)
? repaired
: defaultDirectSelection
}
export function repairAgentRuntimeSelection(
selection: AgentRuntimeSelection,
settings: RuntimeSelectionRepairSettings