feat: add secure remote channel media
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user