feat(plugins): confirm external links from management pages (#635)

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-11-07 15:01:44 +08:00
committed by GitHub
co-authored by Zyy Codex
parent db779a4a21
commit 39c8a6352d
11 changed files with 107 additions and 8 deletions
@@ -692,7 +692,12 @@ keep、replace 或 delete;全部完成后宿主才原子提交新配置与状
宿主注入只读的 `window.codexProxyPlugin`。`request({ method, path, query?, contentType?, body? })` 只能访问当前版本
已注册的管理路由,返回 `{ status, contentType, body: ArrayBuffer }`;`callbackTicket({ path, ttlSeconds })` 为已注册
回调签发 1–600 秒的一次性地址;`resourceUrl(path)` 只解析同一制品已注册的非 HTML 资源。`theme` 为当前
`light` / `dark` 值,变化时触发 `codex-proxy-themechange`。页面不能指定其他实例、制品、版本、URL 或请求 header
`light` / `dark` 值,变化时触发 `codex-proxy-themechange`。管理请求不能指定其他实例、制品、版本、URL 或请求 header
需要跳转到外部页面时,在插件页面的用户点击处理器中调用 `openExternal(url): Promise<void>`。
地址必须是绝对 HTTPS URL,不能包含用户名或密码。宿主展示目标主机名和非默认端口,管理员可打开新标签页、复制链接或取消。
Promise 成功只表示宿主已接受确认请求,不代表链接已打开;无用户操作、地址无效或已有链接待确认时,请求会被拒绝。
切换页面会关闭确认框。兼容较早的 v2 宿主时,先检查 `typeof window.codexProxyPlugin.openExternal === 'function'`
普通模型请求使用标准 `Response` 接口:
+1
View File
@@ -3232,6 +3232,7 @@ GET/HEAD 不接受正文。响应状态码为 200–599,内容类型元数据
`If-None-Match` 匹配时返回无正文的 `304`。停用、版本或授权变更后,旧目标仍被拒绝,不返回缓存命中。
两类响应默认设置 nosniff、no-referrer 与限制性 CSP,动态处理器可显式覆盖默认响应头。管理端页面使用隔离 iframe,
仅允许脚本,不具有父页面同源权限;联网、弹窗、表单、嵌套页面和顶层导航均不开放。
插件页面可通过宿主桥请求打开 HTTPS 外链,管理员在宿主确认后才打开新标签页。
页面访问已声明管理路由须经过固定目标的宿主桥,不能指定其他实例或版本。插件页面作者合同见
[SDK 管理 API 与页面](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#管理页面公开入口与-cli)
+1 -1
View File
@@ -78,7 +78,7 @@ flowchart LR
| 功能 | 使用方式 |
| --- | --- |
| 管理页面 | 从插件详情或左侧插件导航打开;页面在隔离 iframe 中运行 |
| 管理页面 | 从插件详情或左侧插件导航打开;插件请求打开外链时,核对目标域名后可打开、复制或取消 |
| 自动维护 | 启用、重启及账号等配置变化后自动对账;失败重试,周期补漏 |
| 页面模型请求 | 每次选择 Client Key,经固定实例版本的 Responses 桥执行,返回原生 JSON 或 SSE |
| 请求中间件、模型路由、账号调度、重试策略、请求观察 | 匹配功能范围的正常网关请求自动使用;重试策略故障时交给后续处理 |
@@ -330,6 +330,12 @@
"modelResponseReadFailed": "Model response read failed",
"duplicateManagementRequest": "Duplicate plugin management request ID",
"managementConcurrencyLimit": "Plugin management concurrency limit reached",
"openExternalLink": "Open external link",
"openLink": "Open",
"copyLink": "Copy link",
"externalLinkPending": "An external link is already awaiting confirmation",
"externalLinkRequiresClick": "Click a link or button on the plugin page",
"invalidExternalLink": "Use an absolute HTTPS URL without a username or password",
"invalidEntryPoint": "Plugin page entry point is not a registered HTML resource",
"unregisteredAsset": "Plugin page references an unregistered resource: {path}",
"htmlSubresource": "Plugin HTML resources cannot be loaded as subresources",
@@ -330,6 +330,12 @@
"modelResponseReadFailed": "模型响应读取失败",
"duplicateManagementRequest": "插件管理请求序号重复",
"managementConcurrencyLimit": "插件管理请求已达并发上限",
"openExternalLink": "打开外部链接",
"openLink": "打开",
"copyLink": "复制链接",
"externalLinkPending": "请先处理当前外部链接",
"externalLinkRequiresClick": "请在插件页面中点击链接或按钮",
"invalidExternalLink": "请输入不含用户名和密码的 HTTPS 绝对地址",
"invalidEntryPoint": "插件页面入口不是已注册的 HTML 资源",
"unregisteredAsset": "插件页面引用了未注册资源:{path}",
"htmlSubresource": "插件 HTML 资源不能作为子资源加载",
@@ -330,6 +330,12 @@
"modelResponseReadFailed": "模型響應讀取失敗",
"duplicateManagementRequest": "插件管理請求序號重複",
"managementConcurrencyLimit": "插件管理請求已達併發上限",
"openExternalLink": "開啟外部連結",
"openLink": "開啟",
"copyLink": "複製連結",
"externalLinkPending": "請先處理目前的外部連結",
"externalLinkRequiresClick": "請在插件頁面中點選連結或按鈕",
"invalidExternalLink": "請輸入不含使用者名稱和密碼的 HTTPS 絕對網址",
"invalidEntryPoint": "插件頁面入口不是已註冊的 HTML 資源",
"unregisteredAsset": "插件頁面引用了未註冊資源:{path}",
"htmlSubresource": "插件 HTML 資源不能作爲子資源加載",
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { ZButton, ZDialog } from '@codex-proxy/ui'
import { onMounted, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCopyText } from '@/composables/useCopyText'
const props = defineProps<{ link: URL }>()
defineEmits<{ closed: [] }>()
const { t } = useI18n({ useScope: 'global' })
const copyText = useCopyText()
const open = shallowRef(false)
// 确认框按请求挂载,挂载后打开才会触发入场动画
onMounted(() => {
open.value = true
})
function openLink() {
window.open(props.link.href, '_blank', 'noopener,noreferrer')
open.value = false
}
</script>
<template>
<ZDialog
v-model="open"
:title="t('plugins.frame.openExternalLink')"
width="28rem"
@closed="$emit('closed')"
>
<template #description>
<bdi dir="ltr" class="break-all">{{ link.host }}</bdi>
</template>
<template #footer>
<ZButton @click="open = false">
{{ t('common.actions.cancel') }}
</ZButton>
<ZButton @click="copyText(link.href)">
{{ t('plugins.frame.copyLink') }}
</ZButton>
<ZButton type="primary" @click="openLink">
{{ t('plugins.frame.openLink') }}
</ZButton>
</template>
</ZDialog>
</template>
@@ -5,6 +5,7 @@ import { useEventListener, useResizeObserver } from '@vueuse/core'
import { computed, onMounted, shallowRef, useTemplateRef, watch } from 'vue'
import { useThemeStore } from '@/stores/modules/theme'
import { usePluginFrameSession } from '../composables/usePluginFrameSession'
import PluginExternalLinkDialog from './PluginExternalLinkDialog.vue'
import PluginPageLoading from './PluginPageLoading.vue'
const props = defineProps<{
@@ -20,7 +21,7 @@ const themeStore = useThemeStore()
const containerRef = useTemplateRef<HTMLDivElement>('container')
const iframeRef = useTemplateRef<HTMLIFrameElement>('iframe')
const viewportHeight = shallowRef(0)
const { srcdoc, loading, loadError, contentHeight, frameKey, handleFrameLoad, pushLayout, pushTheme } = usePluginFrameSession({
const { srcdoc, loading, loadError, contentHeight, externalLink, frameKey, handleFrameLoad, pushLayout, pushTheme } = usePluginFrameSession({
view: () => props.view,
page: () => props.page,
iframe: iframeRef,
@@ -73,5 +74,6 @@ onMounted(pushLayout)
referrerpolicy="no-referrer"
@load="handleFrameLoad"
/>
<PluginExternalLinkDialog v-if="externalLink" :link="externalLink" @closed="externalLink = undefined" />
</div>
</template>
@@ -53,6 +53,7 @@ export function usePluginFrameSession(options: {
ZNotification.error({ message, deduplicate: true })
})
const contentHeight = shallowRef(0)
const externalLink = shallowRef<URL>()
const session = shallowRef<FrameSession>()
let loadController: AbortController | undefined
let previousViewportHeight = -1
@@ -62,6 +63,7 @@ export function usePluginFrameSession(options: {
loadController = undefined
const current = session.value
session.value = undefined
externalLink.value = undefined
srcdoc.value = ''
contentHeight.value = 0
previousViewportHeight = -1
@@ -290,6 +292,20 @@ export function usePluginFrameSession(options: {
}, '*', transfer)
}
function requestExternalLink(value: unknown) {
if (externalLink.value)
throw new Error(t('plugins.frame.externalLinkPending'))
// 短暂激活只能用于限制提示,打开地址仍须管理员在宿主内确认
if (!navigator.userActivation?.isActive || document.activeElement !== options.iframe.value)
throw new Error(t('plugins.frame.externalLinkRequiresClick'))
const url = typeof value === 'string'
? URL.parse(value)
: null
if (!url || url.protocol !== 'https:' || url.username || url.password)
throw new Error(t('plugins.frame.invalidExternalLink'))
externalLink.value = url
}
async function handleBridgeMessage(event: MessageEvent) {
const current = session.value
const source = event.source
@@ -324,7 +340,7 @@ export function usePluginFrameSession(options: {
current.models.cancel(frame, event.data.id)
return
}
if (!['request', 'callback-ticket'].includes(event.data.type))
if (!['request', 'callback-ticket', 'open-external'].includes(event.data.type))
return
if (current.requestIds.has(event.data.id) || current.models.has(event.data.id)) {
postResult(current, frame, event.data.id, false, undefined, t('plugins.frame.duplicateManagementRequest'))
@@ -336,9 +352,16 @@ export function usePluginFrameSession(options: {
}
current.requestIds.add(event.data.id)
try {
if (event.data.type === 'request')
if (event.data.type === 'request') {
await handleRouteRequest(current, frame, event.data)
else await handleCallbackTicket(current, frame, event.data)
}
else if (event.data.type === 'callback-ticket') {
await handleCallbackTicket(current, frame, event.data)
}
else {
requestExternalLink(event.data.payload)
postResult(current, frame, event.data.id, true)
}
}
catch (error) {
if (session.value !== current || current.controller.signal.aborted)
@@ -412,6 +435,7 @@ export function usePluginFrameSession(options: {
loading,
loadError,
contentHeight,
externalLink,
frameKey,
handleFrameLoad,
pushLayout,
@@ -9,7 +9,7 @@ export const MAXIMUM_MODEL_IN_FLIGHT = 4
export const MODEL_REQUEST_DEADLINE_MS = 10 * 60 * 1000
export const MODEL_RESPONSE_IDLE_MS = 30 * 1000
export type PluginPageMessageType = 'request' | 'callback-ticket' | 'resize' | 'models-request' | 'models-pull' | 'models-cancel'
export type PluginPageMessageType = 'request' | 'callback-ticket' | 'open-external' | 'resize' | 'models-request' | 'models-pull' | 'models-cancel'
export type PluginModelMessageType = 'models-response' | 'models-chunk' | 'models-complete' | 'models-error'
export interface PluginBridgeMessage {
@@ -63,6 +63,7 @@ export interface PluginHostBridge {
readonly theme: 'light' | 'dark'
request: (input: PluginManagementRequest) => Promise<PluginManagementReply>
callbackTicket: (input: { path: string, ttlSeconds: number }) => Promise<PluginCallbackTicket>
openExternal: (url: string) => Promise<void>
resourceUrl: (path: string) => string
readonly models: Readonly<{ responses: (input: PluginModelRequest) => Promise<Response> }>
}
@@ -80,7 +80,7 @@ export default function installPluginBridge(initial: PluginBridgeInitial) {
isDisposed: () => disposed,
})
function invoke<T>(kind: 'request' | 'callback-ticket', payload: unknown, transfer: Transferable[] = []): Promise<T> {
function invoke<T>(kind: 'request' | 'callback-ticket' | 'open-external', payload: unknown, transfer: Transferable[] = []): Promise<T> {
if (disposed)
return Promise.reject(new Error('Plugin page is closed'))
if (pending.size >= maximumInFlight)
@@ -254,6 +254,7 @@ export default function installPluginBridge(initial: PluginBridgeInitial) {
get theme() { return currentTheme },
request,
callbackTicket,
openExternal: url => invoke<void>('open-external', url),
models: Object.freeze({ responses: modelBridge.responses }),
resourceUrl(path: string) {
const key = String(path)