chore: prepare GoodBuddy 0.8.5
This commit is contained in:
@@ -75,6 +75,7 @@ export const conversationSnapshotSchema = z
|
||||
id: assistantIdSchema,
|
||||
role: z.enum(['user', 'assistant']),
|
||||
content: z.string().max(1_000_000),
|
||||
reasoning: z.string().optional(),
|
||||
createdAt: z.number().int().nonnegative(),
|
||||
state: z.enum(['streaming', 'complete', 'error']),
|
||||
status: z.string().max(4_000).optional(),
|
||||
|
||||
@@ -217,16 +217,10 @@ const mcpRemoteUrlSchema = z
|
||||
.url()
|
||||
.max(2_048)
|
||||
.superRefine((value, context) => {
|
||||
const url = new URL(value)
|
||||
if (
|
||||
!['http:', 'https:'].includes(url.protocol) ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.hash
|
||||
) {
|
||||
if (!['http:', 'https:'].includes(new URL(value).protocol)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: 'MCP URL 必须是无凭据和片段的 HTTP(S) 地址'
|
||||
message: 'MCP URL 必须使用 HTTP 或 HTTPS'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
+23
-74
@@ -43,7 +43,6 @@ import type {
|
||||
ManagedChannel,
|
||||
WeComChannelSettingsInput
|
||||
} from './channel-settings-contracts'
|
||||
import { isIntranetHostname } from './intranet-hostname'
|
||||
import type {
|
||||
ApplicationSettings,
|
||||
VersionCheckResult
|
||||
@@ -203,7 +202,6 @@ export const defaultRuntimeSettings = {
|
||||
continueMode: 'chat',
|
||||
runtimeSandboxMode: 'auto',
|
||||
subagentSmartRoutingEnabled: false,
|
||||
intranetCompatibilityEnabled: false,
|
||||
knowledgeEmbeddingEnabled: false,
|
||||
knowledgeEmbeddingBaseUrl:
|
||||
'http://127.0.0.1:11434/v1/embeddings',
|
||||
@@ -324,7 +322,6 @@ export const runtimeSettingsInputSchema = z
|
||||
continueMode: continueModeSchema,
|
||||
runtimeSandboxMode: runtimeSandboxModeSchema,
|
||||
subagentSmartRoutingEnabled: z.boolean().optional(),
|
||||
intranetCompatibilityEnabled: z.boolean().default(false),
|
||||
knowledgeEmbeddingEnabled: z.boolean(),
|
||||
knowledgeEmbeddingBaseUrl: z.string().url().max(2_048),
|
||||
knowledgeEmbeddingModel: z
|
||||
@@ -359,32 +356,11 @@ export const runtimeSettingsInputSchema = z
|
||||
value: profile.baseUrl
|
||||
})) ?? [{ path: ['modelBaseUrl'], value: settings.modelBaseUrl }]
|
||||
for (const endpoint of endpoints) {
|
||||
const url = new URL(endpoint.value)
|
||||
const hostname = url.hostname.toLowerCase()
|
||||
const loopback =
|
||||
hostname === 'localhost' ||
|
||||
hostname === '::1' ||
|
||||
hostname === '[::1]' ||
|
||||
/^127(?:\.\d{1,3}){3}$/u.test(hostname)
|
||||
if (
|
||||
!(
|
||||
url.protocol === 'https:' ||
|
||||
(url.protocol === 'http:' &&
|
||||
(loopback ||
|
||||
(settings.intranetCompatibilityEnabled &&
|
||||
isIntranetHostname(hostname))))
|
||||
) ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.search ||
|
||||
url.hash
|
||||
) {
|
||||
if (!['http:', 'https:'].includes(new URL(endpoint.value).protocol)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: endpoint.path,
|
||||
message: settings.intranetCompatibilityEnabled
|
||||
? '模型服务地址必须使用 HTTP(S),且不得包含凭据、查询参数或片段'
|
||||
: '模型服务地址必须使用 HTTPS;仅本机回环地址可使用 HTTP,且不得包含凭据、查询参数或片段'
|
||||
message: '模型服务地址必须使用 HTTP 或 HTTPS'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -473,58 +449,27 @@ export const runtimeSettingsInputSchema = z
|
||||
})
|
||||
}
|
||||
}
|
||||
if (settings.opencodeBaseUrl) {
|
||||
const opencodeUrl = new URL(settings.opencodeBaseUrl)
|
||||
if (
|
||||
!['http:', 'https:'].includes(opencodeUrl.protocol) ||
|
||||
opencodeUrl.username ||
|
||||
opencodeUrl.password ||
|
||||
opencodeUrl.search ||
|
||||
opencodeUrl.hash ||
|
||||
(opencodeUrl.pathname !== '/' && opencodeUrl.pathname !== '')
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['opencodeBaseUrl'],
|
||||
message: 'OpenCode 地址必须是无凭据和路径的 HTTP(S) origin'
|
||||
})
|
||||
}
|
||||
}
|
||||
const embeddingUrl = new URL(settings.knowledgeEmbeddingBaseUrl)
|
||||
const embeddingHost = embeddingUrl.hostname.toLowerCase()
|
||||
const privateIpv4 =
|
||||
/^10(?:\.\d{1,3}){3}$/u.test(embeddingHost) ||
|
||||
/^192\.168(?:\.\d{1,3}){2}$/u.test(embeddingHost) ||
|
||||
/^172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}$/u.test(
|
||||
embeddingHost
|
||||
)
|
||||
const loopback =
|
||||
embeddingHost === 'localhost' ||
|
||||
embeddingHost === '::1' ||
|
||||
embeddingHost === '[::1]' ||
|
||||
/^127(?:\.\d{1,3}){3}$/u.test(embeddingHost)
|
||||
if (
|
||||
!(
|
||||
embeddingUrl.protocol === 'https:' ||
|
||||
(embeddingUrl.protocol === 'http:' &&
|
||||
((settings.intranetCompatibilityEnabled &&
|
||||
isIntranetHostname(embeddingHost)) ||
|
||||
loopback ||
|
||||
privateIpv4))
|
||||
) ||
|
||||
embeddingUrl.username ||
|
||||
embeddingUrl.password ||
|
||||
embeddingUrl.search ||
|
||||
embeddingUrl.hash ||
|
||||
embeddingUrl.pathname === '/' ||
|
||||
embeddingUrl.pathname === ''
|
||||
settings.opencodeBaseUrl &&
|
||||
!['http:', 'https:'].includes(
|
||||
new URL(settings.opencodeBaseUrl).protocol
|
||||
)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['opencodeBaseUrl'],
|
||||
message: 'OpenCode 地址必须使用 HTTP 或 HTTPS'
|
||||
})
|
||||
}
|
||||
if (
|
||||
!['http:', 'https:'].includes(
|
||||
new URL(settings.knowledgeEmbeddingBaseUrl).protocol
|
||||
)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['knowledgeEmbeddingBaseUrl'],
|
||||
message: settings.intranetCompatibilityEnabled
|
||||
? '向量接口 URL 必须是完整的 HTTP(S) 端点,且不得包含凭据、查询参数或片段'
|
||||
: '向量接口 URL 必须是完整的 HTTPS 端点;本机或私有网络可使用 HTTP,且不得包含凭据、查询参数或片段'
|
||||
message: '向量接口 URL 必须使用 HTTP 或 HTTPS'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -561,7 +506,6 @@ export type RuntimeSettings = {
|
||||
continueMode: RuntimeSettingsInput['continueMode']
|
||||
runtimeSandboxMode: RuntimeSettingsInput['runtimeSandboxMode']
|
||||
subagentSmartRoutingEnabled: boolean
|
||||
intranetCompatibilityEnabled: boolean
|
||||
knowledgeEmbeddingEnabled: boolean
|
||||
knowledgeEmbeddingBaseUrl: string
|
||||
knowledgeEmbeddingModel: string
|
||||
@@ -675,6 +619,11 @@ export type AgentEvent =
|
||||
type: 'text'
|
||||
delta: string
|
||||
}
|
||||
| {
|
||||
requestId: string
|
||||
type: 'reasoning'
|
||||
delta: string
|
||||
}
|
||||
| {
|
||||
requestId: string
|
||||
type: 'tool'
|
||||
|
||||
@@ -7,14 +7,10 @@ const safeEndpointSchema = z
|
||||
.url()
|
||||
.trim()
|
||||
.max(2_048)
|
||||
.refine((value) => {
|
||||
const url = new URL(value)
|
||||
return (
|
||||
['http:', 'https:'].includes(url.protocol) &&
|
||||
!url.username &&
|
||||
!url.password
|
||||
)
|
||||
}, 'endpoint must be an HTTP URL without credentials')
|
||||
.refine(
|
||||
(value) => ['http:', 'https:'].includes(new URL(value).protocol),
|
||||
'endpoint must be an HTTP or HTTPS URL'
|
||||
)
|
||||
|
||||
export const embeddingErrorCodeSchema = z.enum([
|
||||
'model_not_found',
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isIntranetHostname } from './intranet-hostname'
|
||||
|
||||
describe('isIntranetHostname', () => {
|
||||
it.each([
|
||||
'localhost',
|
||||
'printer',
|
||||
'models.internal',
|
||||
'models.corp.local',
|
||||
'10.7.0.23',
|
||||
'127.0.0.2',
|
||||
'100.64.0.1',
|
||||
'172.16.4.2',
|
||||
'192.168.1.20',
|
||||
'[fd12:3456::1]'
|
||||
])('accepts the intranet host %s', (hostname) => {
|
||||
expect(isIntranetHostname(hostname)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'models.example.com',
|
||||
'8.8.8.8',
|
||||
'169.254.169.254',
|
||||
'100.100.100.200',
|
||||
'[fd00:ec2::254]',
|
||||
'metadata.google.internal'
|
||||
])('rejects the public or metadata host %s', (hostname) => {
|
||||
expect(isIntranetHostname(hostname)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,95 +0,0 @@
|
||||
const INTRANET_HOST_SUFFIXES = [
|
||||
'.home',
|
||||
'.internal',
|
||||
'.intranet',
|
||||
'.lan',
|
||||
'.local',
|
||||
'.localdomain',
|
||||
'.localhost'
|
||||
] as const
|
||||
|
||||
const BLOCKED_HOSTNAMES = new Set([
|
||||
'100.100.100.200',
|
||||
'fd00:ec2::254',
|
||||
'instance-data',
|
||||
'instance-data.ec2.internal',
|
||||
'metadata',
|
||||
'metadata.aws.internal',
|
||||
'metadata.google.internal'
|
||||
])
|
||||
|
||||
function normalizeHostname(hostname: string): string {
|
||||
const normalized = hostname.trim().toLowerCase().replace(/\.$/u, '')
|
||||
return normalized.startsWith('[') && normalized.endsWith(']')
|
||||
? normalized.slice(1, -1)
|
||||
: normalized
|
||||
}
|
||||
|
||||
function parseIpv4(hostname: string): readonly number[] | undefined {
|
||||
const octets = hostname.split('.')
|
||||
if (
|
||||
octets.length !== 4 ||
|
||||
octets.some(
|
||||
(octet) =>
|
||||
!/^(?:0|[1-9]\d{0,2})$/u.test(octet) ||
|
||||
Number(octet) > 255
|
||||
)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return octets.map(Number)
|
||||
}
|
||||
|
||||
function isIntranetIpv4(hostname: string): boolean {
|
||||
const octets = parseIpv4(hostname)
|
||||
if (!octets) {
|
||||
return false
|
||||
}
|
||||
const [first = -1, second = -1] = octets
|
||||
return (
|
||||
first === 10 ||
|
||||
first === 127 ||
|
||||
(first === 100 && second >= 64 && second <= 127) ||
|
||||
(first === 172 && second >= 16 && second <= 31) ||
|
||||
(first === 192 && second === 168)
|
||||
)
|
||||
}
|
||||
|
||||
function isIntranetIpv6(hostname: string): boolean {
|
||||
const withoutZone = hostname.split('%', 1)[0] ?? ''
|
||||
return (
|
||||
withoutZone === '::1' ||
|
||||
/^f[cd][0-9a-f]{2}(?::|$)/u.test(withoutZone)
|
||||
)
|
||||
}
|
||||
|
||||
export function isLoopbackHostname(hostname: string): boolean {
|
||||
const normalized = normalizeHostname(hostname)
|
||||
const ipv4 = parseIpv4(normalized)
|
||||
return (
|
||||
normalized === 'localhost' ||
|
||||
normalized === '::1' ||
|
||||
ipv4?.[0] === 127
|
||||
)
|
||||
}
|
||||
|
||||
export function isIntranetHostname(hostname: string): boolean {
|
||||
const normalized = normalizeHostname(hostname)
|
||||
if (!normalized || BLOCKED_HOSTNAMES.has(normalized)) {
|
||||
return false
|
||||
}
|
||||
if (parseIpv4(normalized)) {
|
||||
return isIntranetIpv4(normalized)
|
||||
}
|
||||
if (normalized.includes(':')) {
|
||||
return isIntranetIpv6(normalized)
|
||||
}
|
||||
return (
|
||||
isLoopbackHostname(normalized) ||
|
||||
!normalized.includes('.') ||
|
||||
INTRANET_HOST_SUFFIXES.some(
|
||||
(suffix) =>
|
||||
normalized === suffix.slice(1) || normalized.endsWith(suffix)
|
||||
)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user