fix: harden shared agent source permissions

This commit is contained in:
wizardchen
2026-07-30 12:28:23 +08:00
committed by lyingbug
parent 57fb1de45d
commit ab9f705843
31 changed files with 510 additions and 153 deletions
+4 -1
View File
@@ -36,7 +36,7 @@ export function useStream() {
let renderTimer: number | null = null
// 启动流式请求
const startStream = async (params: { session_id: any; query: any; knowledge_base_ids?: string[]; knowledge_ids?: string[]; tag_ids?: string[]; agent_enabled?: boolean; agent_id?: string; web_search_enabled?: boolean; summary_model_id?: string; mcp_service_ids?: string[]; skill_names?: string[]; mentioned_items?: Array<{id: string; name: string; type: string; kb_type?: string; kb_id?: string; kb_name?: string; service_id?: string; skill_name?: string}>; images?: Array<{data: string}>; attachment_uploads?: Array<{data: string; file_name: string; file_size: number}>; attachment_ids?: string[]; suggestion_attribution?: { suggestion_set_id: string; question_id: string }; method: string; url: string; embed_token?: string; embed_session_sig?: string; embed_visitor_id?: string }) => {
const startStream = async (params: { session_id: any; query: any; knowledge_base_ids?: string[]; knowledge_ids?: string[]; tag_ids?: string[]; agent_enabled?: boolean; agent_id?: string; agent_source_tenant_id?: string | number; web_search_enabled?: boolean; summary_model_id?: string; mcp_service_ids?: string[]; skill_names?: string[]; mentioned_items?: Array<{id: string; name: string; type: string; kb_type?: string; kb_id?: string; kb_name?: string; service_id?: string; skill_name?: string}>; images?: Array<{data: string}>; attachment_uploads?: Array<{data: string; file_name: string; file_size: number}>; attachment_ids?: string[]; suggestion_attribution?: { suggestion_set_id: string; question_id: string }; method: string; url: string; embed_token?: string; embed_session_sig?: string; embed_visitor_id?: string }) => {
const myGeneration = ++streamGeneration
// 重置状态
output.value = '';
@@ -99,6 +99,9 @@ export function useStream() {
if (params.agent_id) {
postBody.agent_id = params.agent_id;
}
if (params.agent_source_tenant_id) {
postBody.agent_source_tenant_id = Number(params.agent_source_tenant_id);
}
// Include web_search_enabled if provided
if (params.web_search_enabled !== undefined) {
postBody.web_search_enabled = params.web_search_enabled;
@@ -26,12 +26,14 @@ export function uploadTemporaryAttachment(
sessionId: string,
file: File,
agentId?: string,
agentSourceTenantId?: string,
parserEngine?: string,
onProgress?: (percent: number) => void,
): Promise<AttachmentResponse> {
const form = new FormData();
form.append('file', file);
if (agentId) form.append('agent_id', agentId);
if (agentSourceTenantId) form.append('agent_source_tenant_id', agentSourceTenantId);
if (parserEngine) form.append('parser_engine', parserEngine);
return postUpload(
`/api/v1/sessions/${sessionId}/attachments`,
+10 -4
View File
@@ -29,6 +29,7 @@ export async function listKnowledgeBaseActivity(
// 知识库管理 API(列表、创建、获取、更新、删除、复制)
export function listKnowledgeBases(params?: {
agent_id?: string;
agent_source_tenant_id?: string;
/**
* Optional creator filter. Server-side semantics:
* - "mine" → only KBs whose creator_id matches the caller
@@ -41,6 +42,7 @@ export function listKnowledgeBases(params?: {
}) {
const query = new URLSearchParams();
if (params?.agent_id) query.set('agent_id', params.agent_id);
if (params?.agent_source_tenant_id) query.set('agent_source_tenant_id', params.agent_source_tenant_id);
if (params?.creator && params.creator !== 'all') query.set('creator', params.creator);
const qs = query.toString();
return get(qs ? `/api/v1/knowledge-bases?${qs}` : '/api/v1/knowledge-bases');
@@ -125,9 +127,10 @@ export function createKnowledgeBase(data: {
return post(`/api/v1/knowledge-bases`, data);
}
export function getKnowledgeBaseById(id: string, options?: { agent_id?: string }) {
export function getKnowledgeBaseById(id: string, options?: { agent_id?: string; agent_source_tenant_id?: string }) {
const query = new URLSearchParams();
if (options?.agent_id) query.set('agent_id', options.agent_id);
if (options?.agent_source_tenant_id) query.set('agent_source_tenant_id', options.agent_source_tenant_id);
const qs = query.toString();
return get(qs ? `/api/v1/knowledge-bases/${id}?${qs}` : `/api/v1/knowledge-bases/${id}`);
}
@@ -277,9 +280,10 @@ export function listKnowledgeFiles(
return get(`/api/v1/knowledge-bases/${kbId}/knowledge?${qs}`);
}
export function getKnowledgeDetails(id: string, options?: { agent_id?: string }) {
export function getKnowledgeDetails(id: string, options?: { agent_id?: string; agent_source_tenant_id?: string }) {
const query = new URLSearchParams();
if (options?.agent_id) query.set('agent_id', options.agent_id);
if (options?.agent_source_tenant_id) query.set('agent_source_tenant_id', options.agent_source_tenant_id);
const qs = query.toString();
return get(qs ? `/api/v1/knowledge/${id}?${qs}` : `/api/v1/knowledge/${id}`);
}
@@ -322,10 +326,11 @@ export function previewKnowledgeFile(id: string) {
}
/** @param idsQueryString - query string with ids (e.g. ids=xxx&ids=yyy) */
export function batchQueryKnowledge(idsQueryString: string, kbId?: string, agentId?: string) {
export function batchQueryKnowledge(idsQueryString: string, kbId?: string, agentId?: string, agentSourceTenantId?: string) {
let qs = idsQueryString;
if (kbId) qs += `&kb_id=${encodeURIComponent(kbId)}`;
if (agentId) qs += `&agent_id=${encodeURIComponent(agentId)}`;
if (agentSourceTenantId) qs += `&agent_source_tenant_id=${encodeURIComponent(agentSourceTenantId)}`;
return get(`/api/v1/knowledge/batch?${qs}`);
}
@@ -539,7 +544,7 @@ export function searchKnowledge(
offset = 0,
limit = 20,
fileTypes?: string[],
options?: { agent_id?: string; recent?: boolean }
options?: { agent_id?: string; agent_source_tenant_id?: string; recent?: boolean }
) {
const query = new URLSearchParams();
if (keyword) {
@@ -551,6 +556,7 @@ export function searchKnowledge(
query.set('file_types', fileTypes.join(','));
}
if (options?.agent_id) query.set('agent_id', options.agent_id);
if (options?.agent_source_tenant_id) query.set('agent_source_tenant_id', options.agent_source_tenant_id);
if (options?.recent) query.set('recent', 'true');
return get(`/api/v1/knowledge/search?${query.toString()}`);
}
+2
View File
@@ -311,6 +311,8 @@ export interface SharedAgentInfo {
shared_at: string
shared_by_user_id?: string
shared_by_username?: string
/** 由后端在源空间解析,不与当前空间的搜索引擎列表比较 */
web_search_ready: boolean
/** 当前用户是否已停用该共享智能体(仅影响本人对话下拉显示) */
disabled_by_me?: boolean
}
+7 -2
View File
@@ -228,7 +228,7 @@ const emit = defineEmits<{
type AgentDetailTarget = {
agent: CustomAgent;
sourceTenantId?: string;
sharedMeta?: { org_name?: string; shared_by_username?: string };
sharedMeta?: { org_name?: string; shared_by_username?: string; web_search_ready?: boolean };
};
type SharedAgentSelection = Omit<SharedAgentInfo, 'agent'> & {
@@ -322,7 +322,11 @@ const isWebSearchEnabledForAgent = (agent: CustomAgent): boolean => {
};
const isWebSearchReadyForAgent = (agent: CustomAgent): boolean => {
return isAgentWebSearchReady(agent.config, webSearchProviders.value);
return isAgentWebSearchReady(
agent.config,
webSearchProviders.value,
activeDetail.value?.sourceTenantId ? activeDetail.value.sharedMeta?.web_search_ready : undefined,
);
};
const isImageUploadEnabledForAgent = (agent: CustomAgent): boolean => {
@@ -475,6 +479,7 @@ const onSharedOptionEnter = (shared: SharedAgentSelection, event: MouseEvent) =>
onOptionEnter(shared.agent, event, String(shared.source_tenant_id), {
org_name: shared.org_name,
shared_by_username: shared.shared_by_username,
web_search_ready: shared.web_search_ready,
});
};
@@ -32,6 +32,7 @@ const props = defineProps<{
disabled?: boolean;
sessionId?: string;
agentId?: string;
agentSourceTenantId?: string;
}>();
const emit = defineEmits<{
@@ -139,6 +140,7 @@ const uploadAttachment = async (attachment: AttachmentFile) => {
props.sessionId,
attachment.file,
props.agentId,
props.agentSourceTenantId,
'auto',
(progress) => {
attachment.progress = progress;
+29 -9
View File
@@ -173,6 +173,13 @@ const selectedAgent = computed(() => {
config: { agent_mode: 'quick-answer' as const }
} as CustomAgent;
});
const selectedSharedAgent = computed(() => {
const sourceTenantId = settingsStore.selectedAgentSourceTenantId;
if (!sourceTenantId) return undefined;
return orgStore.sharedAgents?.find(
s => s.agent.id === selectedAgentId.value && String(s.source_tenant_id) === sourceTenantId
);
});
// 判断是否为自定义智能体(非内置)
const isCustomAgent = computed(() => {
@@ -247,7 +254,7 @@ watch([selectedAgentId, agentKnowledgeBases, agentKBSelectionMode], ([newAgentId
watch([selectedAgentId, () => settingsStore.selectedAgentSourceTenantId], async ([agentId, sourceTenantId]) => {
if (sourceTenantId && agentId) {
try {
const list = await chatResources.ensureAgentKnowledgeBases(agentId);
const list = await chatResources.ensureAgentKnowledgeBases(agentId, sourceTenantId);
sharedAgentKbList.value = list.map((kb: any) => ({
id: kb.id,
name: kb.name,
@@ -438,7 +445,11 @@ const showWebSearchButton = computed(() => {
if (!hasAgentConfig.value) {
return isTenantWebSearchReady(webSearchProviders.value);
}
return isAgentWebSearchReady(currentAgentConfig.value, webSearchProviders.value);
return isAgentWebSearchReady(
currentAgentConfig.value,
webSearchProviders.value,
selectedSharedAgent.value?.web_search_ready,
);
});
const showImageUploadButton = computed(() => isImageUploadEnabledByAgent.value);
@@ -721,7 +732,7 @@ const loadKnowledgeBases = async (force = false) => {
const agentId = settingsStore.selectedAgentId;
if (sourceTenantId && agentId) {
try {
const list = await chatResources.ensureAgentKnowledgeBases(agentId, force);
const list = await chatResources.ensureAgentKnowledgeBases(agentId, sourceTenantId, force);
list.forEach((kb: any) => kb?.id && sharedAgentKbIdSet.add(kb.id));
} catch {
sharedAgentKbIdSet = new Set();
@@ -766,7 +777,8 @@ const loadFiles = async () => {
const runBatch = async (batchIds: string[], kbId?: string, agentId?: string) => {
const query = new URLSearchParams();
batchIds.forEach((id: string) => query.append('ids', id));
const res: any = await batchQueryKnowledge(query.toString(), kbId, agentId);
const sourceTenantId = agentId ? settingsStore.selectedAgentSourceTenantId ?? undefined : undefined;
const res: any = await batchQueryKnowledge(query.toString(), kbId, agentId, sourceTenantId);
if (res.data && Array.isArray(res.data)) {
res.data.forEach((f: any) => allNewFiles.push({ id: f.id, name: f.title || f.file_name }));
}
@@ -801,22 +813,29 @@ watch(selectedFileIds, () => {
const isWebSearchConfigured = computed(() => {
if (hasAgentConfig.value) {
return isAgentWebSearchReady(currentAgentConfig.value, webSearchProviders.value);
return isAgentWebSearchReady(
currentAgentConfig.value,
webSearchProviders.value,
selectedSharedAgent.value?.web_search_ready,
);
}
return isTenantWebSearchReady(webSearchProviders.value);
});
const isWebSearchReadinessKnown = computed(
() => !settingsStore.selectedAgentSourceTenantId || selectedSharedAgent.value !== undefined
);
const loadWebSearchConfig = async (force = false) => {
try {
await chatResources.ensureWebSearchProviders(force);
if (!isWebSearchConfigured.value && settingsStore.isWebSearchEnabled) {
if (isWebSearchReadinessKnown.value && !isWebSearchConfigured.value && settingsStore.isWebSearchEnabled) {
settingsStore.toggleWebSearch(false);
}
} catch (error) {
console.error('Failed to load web search config:', error);
chatResources.invalidate('webSearchProviders');
if (settingsStore.isWebSearchEnabled) {
if (!settingsStore.selectedAgentSourceTenantId && settingsStore.isWebSearchEnabled) {
settingsStore.toggleWebSearch(false);
}
}
@@ -1166,7 +1185,7 @@ const loadMentionItems = async (q: string, resetIndex = true, append = false) =>
if (sourceTenantId && agentId) {
// 共享智能体:按 agent_id 拉取该智能体配置的知识库范围(后端从共享关系解析空间)
try {
const list = await chatResources.ensureAgentKnowledgeBases(agentId);
const list = await chatResources.ensureAgentKnowledgeBases(agentId, sourceTenantId);
const orgLabel = sharedAgentOrgName.value || '';
// 保留 capabilities / indexing_strategy,后面过滤时要用
availableKbs = list.map((kb: any) => ({
@@ -1345,7 +1364,7 @@ const loadMentionItems = async (q: string, resetIndex = true, append = false) =>
const sourceTenantId = settingsStore.selectedAgentSourceTenantId;
const agentId = selectedAgentId.value;
const searchOptions = {
...(sourceTenantId && agentId ? { agent_id: agentId } : {}),
...(sourceTenantId && agentId ? { agent_id: agentId, agent_source_tenant_id: sourceTenantId } : {}),
recent: !fileSearchKeyword,
};
const res: any = await searchKnowledge(
@@ -2480,6 +2499,7 @@ defineExpose({
<!-- 附件列表区域 ( AttachmentUpload 组件渲染) -->
<AttachmentUpload ref="attachmentUploadRef" :max-files="5"
:session-id="sessionId" :agent-id="selectedAgentId"
:agent-source-tenant-id="settingsStore.selectedAgentSourceTenantId ?? undefined"
@update:files="uploadedAttachments = $event" />
<!-- 选中的知识库和文件标签显示在输入框内顶部 -->
+9 -2
View File
@@ -308,6 +308,7 @@ const agentIdForDetail = computed(() => {
const agentId = settingsStore.selectedAgentId;
return sourceTenantId && agentId ? agentId : undefined;
});
const agentSourceTenantIdForDetail = computed(() => settingsStore.selectedAgentSourceTenantId ?? undefined);
const kbItems = computed(() => props.items.filter(item => item.type === 'kb'));
const fileItems = computed(() => props.items.filter(item => item.type === 'file'));
@@ -445,7 +446,10 @@ async function fetchKbDetail(item: { id: string }) {
if (detailCache.value[item.id]?.data || detailCache.value[item.id]?.loading) return;
detailCache.value = { ...detailCache.value, [item.id]: { loading: true } };
try {
const opts = agentIdForDetail.value ? { agent_id: agentIdForDetail.value } : undefined;
const opts = agentIdForDetail.value ? {
agent_id: agentIdForDetail.value,
agent_source_tenant_id: agentSourceTenantIdForDetail.value,
} : undefined;
const res: any = await getKnowledgeBaseById(item.id, opts);
detailCache.value = { ...detailCache.value, [item.id]: { loading: false, data: res?.data ?? res } };
} catch (e: any) {
@@ -457,7 +461,10 @@ async function fetchFileDetail(item: { id: string }) {
if (detailCache.value[item.id]?.data || detailCache.value[item.id]?.loading) return;
detailCache.value = { ...detailCache.value, [item.id]: { loading: true } };
try {
const opts = agentIdForDetail.value ? { agent_id: agentIdForDetail.value } : undefined;
const opts = agentIdForDetail.value ? {
agent_id: agentIdForDetail.value,
agent_source_tenant_id: agentSourceTenantIdForDetail.value,
} : undefined;
const res: any = await getKnowledgeDetails(item.id, opts);
detailCache.value = { ...detailCache.value, [item.id]: { loading: false, data: res?.data ?? res } };
} catch (e: any) {
+11 -7
View File
@@ -186,25 +186,29 @@ export const useChatResourcesStore = defineStore('chatResources', () => {
])
}
async function ensureAgentKnowledgeBases(agentId: string, force = false): Promise<any[]> {
const cached = agentKbCache.get(agentId)
async function ensureAgentKnowledgeBases(agentId: string, sourceTenantId?: string, force = false): Promise<any[]> {
const cacheKey = `${agentId}:${sourceTenantId || 'current'}`
const cached = agentKbCache.get(cacheKey)
if (!force && cached && Date.now() - cached.at < CACHE_TTL_MS) {
return cached.data
}
const existing = agentKbInflight.get(agentId)
const existing = agentKbInflight.get(cacheKey)
if (existing) return existing
const p = (async () => {
try {
const res: any = await listKnowledgeBases({ agent_id: agentId })
const res: any = await listKnowledgeBases({
agent_id: agentId,
agent_source_tenant_id: sourceTenantId,
})
const list = res?.data && Array.isArray(res.data) ? res.data : []
agentKbCache.set(agentId, { at: Date.now(), data: list })
agentKbCache.set(cacheKey, { at: Date.now(), data: list })
return list
} finally {
agentKbInflight.delete(agentId)
agentKbInflight.delete(cacheKey)
}
})()
agentKbInflight.set(agentId, p)
agentKbInflight.set(cacheKey, p)
return p
}
@@ -50,6 +50,21 @@ test('isAgentWebSearchReady requires enabled flag and resolvable provider', () =
);
});
test('isAgentWebSearchReady trusts source workspace readiness for a shared agent', () => {
assert.equal(
isAgentWebSearchReady(
{ web_search_enabled: true, web_search_provider_id: 'source-provider' },
[],
true,
),
true,
);
assert.equal(
isAgentWebSearchReady({ web_search_enabled: true }, providers, false),
false,
);
});
test('isTenantWebSearchReady checks default provider only', () => {
assert.equal(isTenantWebSearchReady(providers), true);
assert.equal(
+2
View File
@@ -26,8 +26,10 @@ export function isAgentWebSearchEnabled(config: AgentWebSearchConfig | undefined
export function isAgentWebSearchReady(
config: AgentWebSearchConfig | undefined,
providers: WebSearchProviderEntity[],
sourceWorkspaceReady?: boolean,
): boolean {
if (!isAgentWebSearchEnabled(config)) return false;
if (sourceWorkspaceReady !== undefined) return sourceWorkspaceReady;
return resolveAgentWebSearchProviderId(config, providers) !== null;
}
+8 -2
View File
@@ -664,6 +664,9 @@ const sendMsg = async (value, modelId = '', mentionedItems = [], imageFiles = []
isReplying.value = true;
loading.value = true;
const selectedAgentId = props.embeddedMode ? props.agentId : (useSettingsStoreInstance.selectedAgentId || '');
const selectedAgentSourceTenantId = props.embeddedMode
? undefined
: (useSettingsStoreInstance.selectedAgentSourceTenantId || undefined);
// Images are unified with the attachment pipeline: on the authenticated web
// client they upload as temporary documents (understood in the background by
@@ -690,7 +693,9 @@ const sendMsg = async (value, modelId = '', mentionedItems = [], imageFiles = []
continue;
}
try {
const upload = await uploadTemporaryAttachment(session_id.value, file, selectedAgentId, 'auto');
const upload = await uploadTemporaryAttachment(
session_id.value, file, selectedAgentId, selectedAgentSourceTenantId, 'auto'
);
imageAttachmentIds.push(upload.data.id);
} catch (e) {
console.error('[Image] Temporary image upload failed, falling back to inline:', e);
@@ -710,7 +715,7 @@ const sendMsg = async (value, modelId = '', mentionedItems = [], imageFiles = []
await Promise.all(localAttachments.map(async (attachment) => {
attachment.status = 'uploading';
const upload = await uploadTemporaryAttachment(
session_id.value, attachment.file, selectedAgentId, 'auto'
session_id.value, attachment.file, selectedAgentId, selectedAgentSourceTenantId, 'auto'
);
attachment.documentId = upload.data.id;
attachment.status = upload.data.status;
@@ -818,6 +823,7 @@ const sendMsg = async (value, modelId = '', mentionedItems = [], imageFiles = []
knowledge_ids: knowledgeIds,
agent_enabled: agentEnabled,
agent_id: selectedAgentId,
agent_source_tenant_id: selectedAgentSourceTenantId,
web_search_enabled: webSearchEnabled,
summary_model_id: modelId,
mcp_service_ids: requestMcpServiceIds,
@@ -209,3 +209,33 @@ func (r *agentShareRepository) GetShareByAgentIDForTenant(ctx context.Context, t
}
return &share, nil
}
// GetShareByAgentIDAndSourceForTenant validates an exact source selector
// against organization membership. This avoids loading every shared agent and
// makes same-ID builtins from multiple workspaces deterministic.
func (r *agentShareRepository) GetShareByAgentIDAndSourceForTenant(
ctx context.Context,
tenantID uint64,
agentID string,
sourceTenantID uint64,
) (*types.AgentShare, error) {
var share types.AgentShare
tx := r.db.WithContext(ctx).
Joins("JOIN organization_tenant_members otm ON otm.organization_id = agent_shares.organization_id").
Joins("JOIN organizations ON organizations.id = agent_shares.organization_id AND organizations.deleted_at IS NULL").
Joins("JOIN custom_agents ON custom_agents.id = agent_shares.agent_id AND custom_agents.tenant_id = agent_shares.source_tenant_id AND custom_agents.deleted_at IS NULL").
Where("agent_shares.agent_id = ?", agentID).
Where("agent_shares.source_tenant_id = ?", sourceTenantID).
Where("otm.tenant_id = ?", tenantID).
Where("agent_shares.deleted_at IS NULL").
Order("agent_shares.id").
Limit(1).
Find(&share)
if tx.Error != nil {
return nil, tx.Error
}
if tx.RowsAffected == 0 {
return nil, ErrAgentShareNotFound
}
return &share, nil
}
@@ -0,0 +1,47 @@
package repository
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestGetShareByAgentIDAndSourceForTenantDisambiguatesSource(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:agent-share-source?mode=memory&cache=shared"), &gorm.Config{})
require.NoError(t, err)
statements := []string{
`CREATE TABLE agent_shares (
id TEXT PRIMARY KEY, agent_id TEXT, organization_id TEXT,
shared_by_user_id TEXT, source_tenant_id INTEGER, permission TEXT,
created_at DATETIME, updated_at DATETIME, deleted_at DATETIME
)`,
`CREATE TABLE organization_tenant_members (organization_id TEXT, tenant_id INTEGER)`,
`CREATE TABLE organizations (id TEXT PRIMARY KEY, deleted_at DATETIME)`,
`CREATE TABLE custom_agents (id TEXT, tenant_id INTEGER, deleted_at DATETIME)`,
`INSERT INTO organizations(id) VALUES ('org')`,
`INSERT INTO organization_tenant_members(organization_id, tenant_id) VALUES ('org', 7)`,
`INSERT INTO custom_agents(id, tenant_id) VALUES ('builtin-smart-reasoning', 42), ('builtin-smart-reasoning', 84)`,
`INSERT INTO agent_shares(id, agent_id, organization_id, source_tenant_id) VALUES
('share-42', 'builtin-smart-reasoning', 'org', 42),
('share-84', 'builtin-smart-reasoning', 'org', 84)`,
}
for _, statement := range statements {
require.NoError(t, db.Exec(statement).Error)
}
repo := &agentShareRepository{db: db}
share, err := repo.GetShareByAgentIDAndSourceForTenant(
context.Background(), 7, "builtin-smart-reasoning", 84,
)
require.NoError(t, err)
require.Equal(t, "share-84", share.ID)
require.Equal(t, uint64(84), share.SourceTenantID)
_, err = repo.GetShareByAgentIDAndSourceForTenant(
context.Background(), 8, "builtin-smart-reasoning", 84,
)
require.ErrorIs(t, err, ErrAgentShareNotFound)
}
@@ -438,6 +438,9 @@ func (s *agentService) registerTools(
allowedTools = tools.DefaultAllowedTools()
logger.Infof(ctx, "Using default allowed tools: %v", allowedTools)
}
if config.SharedAgentReadOnly {
allowedTools = filterSharedAgentWriteTools(allowedTools)
}
// ---- Capability detection from SearchTargets ----
var hasVectorKB bool
@@ -679,6 +682,27 @@ func (s *agentService) registerTools(
return nil
}
// filterSharedAgentWriteTools enforces the read-only contract of AgentShare.
// These tools write source-workspace Wiki state and otherwise bypass the HTTP
// KB permission middleware because they execute inside the agent engine.
func filterSharedAgentWriteTools(allowed []string) []string {
sourceWorkspaceWrites := map[string]bool{
tools.ToolWikiFlagIssue: true,
tools.ToolWikiUpdateIssue: true,
tools.ToolWikiWritePage: true,
tools.ToolWikiReplaceText: true,
tools.ToolWikiRenamePage: true,
tools.ToolWikiDeletePage: true,
}
filtered := make([]string, 0, len(allowed))
for _, name := range allowed {
if !sourceWorkspaceWrites[name] {
filtered = append(filtered, name)
}
}
return filtered
}
// ValidateConfig validates the agent configuration
func (s *agentService) ValidateConfig(config *types.AgentConfig) error {
if config == nil {
+98 -67
View File
@@ -62,11 +62,12 @@ func agentRequiresRerankModel(agent *types.CustomAgent) bool {
// tenant. callerTenantRole flows through every read path so the 3-D
// cap (tenant Viewer → at most OrgRoleViewer) lands consistently.
type agentShareService struct {
shareRepo interfaces.AgentShareRepository
disabledRepo interfaces.TenantDisabledSharedAgentRepository
orgRepo interfaces.OrganizationRepository
agentRepo interfaces.CustomAgentRepository
userRepo interfaces.UserRepository
shareRepo interfaces.AgentShareRepository
disabledRepo interfaces.TenantDisabledSharedAgentRepository
orgRepo interfaces.OrganizationRepository
agentRepo interfaces.CustomAgentRepository
userRepo interfaces.UserRepository
webSearchProviderRepo interfaces.WebSearchProviderRepository
}
// NewAgentShareService creates a new agent share service
@@ -76,16 +77,72 @@ func NewAgentShareService(
orgRepo interfaces.OrganizationRepository,
agentRepo interfaces.CustomAgentRepository,
userRepo interfaces.UserRepository,
webSearchProviderRepo interfaces.WebSearchProviderRepository,
) interfaces.AgentShareService {
return &agentShareService{
shareRepo: shareRepo,
disabledRepo: disabledRepo,
orgRepo: orgRepo,
agentRepo: agentRepo,
userRepo: userRepo,
shareRepo: shareRepo,
disabledRepo: disabledRepo,
orgRepo: orgRepo,
agentRepo: agentRepo,
userRepo: userRepo,
webSearchProviderRepo: webSearchProviderRepo,
}
}
func (s *agentShareService) sharedAgentInfo(
ctx context.Context,
share *types.AgentShare,
effective types.OrgMemberRole,
webSearchReadyCache map[string]bool,
) *types.SharedAgentInfo {
info := &types.SharedAgentInfo{
Agent: share.Agent,
ShareID: share.ID,
OrganizationID: share.OrganizationID,
Permission: effective,
SourceTenantID: share.SourceTenantID,
SharedAt: share.CreatedAt,
SharedByUserID: share.SharedByUserID,
}
if share.Organization != nil {
info.OrgName = share.Organization.Name
}
if share.SharedByUserID != "" && s.userRepo != nil {
if u, err := s.userRepo.GetUserByID(ctx, share.SharedByUserID); err == nil && u != nil {
info.SharedByUsername = u.Username
}
}
cacheKey := fmt.Sprintf("%d:%t:%s", share.SourceTenantID, share.Agent.Config.WebSearchEnabled, share.Agent.Config.WebSearchProviderID)
if ready, ok := webSearchReadyCache[cacheKey]; ok {
info.WebSearchReady = ready
} else {
info.WebSearchReady = s.isAgentWebSearchReady(ctx, share.Agent, share.SourceTenantID)
webSearchReadyCache[cacheKey] = info.WebSearchReady
}
return info
}
// isAgentWebSearchReady resolves only an availability bit in the source
// workspace. Returning source provider rows would leak configuration, while
// comparing provider IDs with the receiver workspace produces false negatives.
func (s *agentShareService) isAgentWebSearchReady(
ctx context.Context,
agent *types.CustomAgent,
sourceTenantID uint64,
) bool {
if agent == nil || !agent.Config.WebSearchEnabled || s.webSearchProviderRepo == nil {
return false
}
var provider *types.WebSearchProviderEntity
var err error
if agent.Config.WebSearchProviderID != "" {
provider, err = s.webSearchProviderRepo.GetByID(ctx, sourceTenantID, agent.Config.WebSearchProviderID)
} else {
provider, err = s.webSearchProviderRepo.GetDefault(ctx, sourceTenantID)
}
return err == nil && provider != nil
}
// ShareAgent shares an agent to an organization. Permission is forced to
// OrgRoleViewer (cross-tenant agent edit is not part of v1).
func (s *agentShareService) ShareAgent(ctx context.Context, agentID string, orgID string, userID string, tenantID uint64, permission types.OrgMemberRole) (*types.AgentShare, error) {
@@ -221,6 +278,7 @@ func (s *agentShareService) ListSharedAgents(ctx context.Context, tenantID uint6
}
agentInfoMap := make(map[string]*types.SharedAgentInfo)
webSearchReadyCache := make(map[string]bool)
for _, share := range shares {
if share.SourceTenantID == tenantID {
continue
@@ -234,24 +292,7 @@ func (s *agentShareService) ListSharedAgents(ctx context.Context, tenantID uint6
}
effective := types.MinOrgRole(share.Permission, tm.Role)
effective = applyTenantRoleCap(effective, callerTenantRole)
info := &types.SharedAgentInfo{
Agent: share.Agent,
ShareID: share.ID,
OrganizationID: share.OrganizationID,
OrgName: "",
Permission: effective,
SourceTenantID: share.SourceTenantID,
SharedAt: share.CreatedAt,
SharedByUserID: share.SharedByUserID,
}
if share.Organization != nil {
info.OrgName = share.Organization.Name
}
if share.SharedByUserID != "" {
if u, err := s.userRepo.GetUserByID(ctx, share.SharedByUserID); err == nil && u != nil {
info.SharedByUsername = u.Username
}
}
info := s.sharedAgentInfo(ctx, share, effective, webSearchReadyCache)
key := fmt.Sprintf("%s_%d", share.AgentID, share.SourceTenantID)
existing, exists := agentInfoMap[key]
if !exists {
@@ -302,6 +343,7 @@ func (s *agentShareService) ListSharedAgentsInOrganization(ctx context.Context,
}
result := make([]*types.OrganizationSharedAgentItem, 0, len(shares))
webSearchReadyCache := make(map[string]bool)
for _, share := range shares {
if share.Agent == nil {
continue
@@ -310,25 +352,7 @@ func (s *agentShareService) ListSharedAgentsInOrganization(ctx context.Context,
effective := types.MinOrgRole(share.Permission, tm.Role)
effective = applyTenantRoleCap(effective, callerTenantRole)
orgName := ""
if share.Organization != nil {
orgName = share.Organization.Name
}
info := &types.SharedAgentInfo{
Agent: share.Agent,
ShareID: share.ID,
OrganizationID: share.OrganizationID,
OrgName: orgName,
Permission: effective,
SourceTenantID: share.SourceTenantID,
SharedAt: share.CreatedAt,
SharedByUserID: share.SharedByUserID,
}
if share.SharedByUserID != "" {
if u, err := s.userRepo.GetUserByID(ctx, share.SharedByUserID); err == nil && u != nil {
info.SharedByUsername = u.Username
}
}
info := s.sharedAgentInfo(ctx, share, effective, webSearchReadyCache)
item := &types.OrganizationSharedAgentItem{
SharedAgentInfo: *info,
@@ -380,6 +404,7 @@ func (s *agentShareService) ListSharedAgentsInOrganizations(ctx context.Context,
disabledSet[fmt.Sprintf("%s_%d", d.AgentID, d.SourceTenantID)] = true
}
}
webSearchReadyCache := make(map[string]bool)
for orgID, list := range byOrg {
tm := members[orgID]
result := make([]*types.OrganizationSharedAgentItem, 0, len(list))
@@ -389,25 +414,7 @@ func (s *agentShareService) ListSharedAgentsInOrganizations(ctx context.Context,
}
effective := types.MinOrgRole(share.Permission, tm.Role)
effective = applyTenantRoleCap(effective, callerTenantRole)
orgName := ""
if share.Organization != nil {
orgName = share.Organization.Name
}
info := &types.SharedAgentInfo{
Agent: share.Agent,
ShareID: share.ID,
OrganizationID: share.OrganizationID,
OrgName: orgName,
Permission: effective,
SourceTenantID: share.SourceTenantID,
SharedAt: share.CreatedAt,
SharedByUserID: share.SharedByUserID,
}
if share.SharedByUserID != "" {
if u, err := s.userRepo.GetUserByID(ctx, share.SharedByUserID); err == nil && u != nil {
info.SharedByUsername = u.Username
}
}
info := s.sharedAgentInfo(ctx, share, effective, webSearchReadyCache)
item := &types.OrganizationSharedAgentItem{
SharedAgentInfo: *info,
IsMine: share.SourceTenantID == tenantID,
@@ -442,10 +449,34 @@ func (s *agentShareService) SetSharedAgentDisabledByMe(ctx context.Context, tena
// callerTenantRole is currently only used for symmetry / future caps on
// agent execution (e.g. tenant Viewers might be banned from
// state-changing tool calls in a follow-up).
func (s *agentShareService) GetSharedAgentForTenant(ctx context.Context, tenantID uint64, callerTenantRole types.TenantRole, agentID string) (*types.CustomAgent, error) {
func (s *agentShareService) GetSharedAgentForTenant(
ctx context.Context,
tenantID uint64,
callerTenantRole types.TenantRole,
agentID string,
sourceTenantID ...uint64,
) (*types.CustomAgent, error) {
if agentID == "" {
return nil, ErrAgentShareNotFound
}
if len(sourceTenantID) > 0 && sourceTenantID[0] != 0 {
if sourceTenantID[0] == tenantID {
return nil, ErrAgentShareNotFound
}
share, err := s.shareRepo.GetShareByAgentIDAndSourceForTenant(ctx, tenantID, agentID, sourceTenantID[0])
if err != nil {
if errors.Is(err, repository.ErrAgentShareNotFound) {
return nil, ErrAgentSharePermission
}
return nil, err
}
agent, err := s.agentRepo.GetAgentByID(ctx, agentID, share.SourceTenantID)
if err != nil || agent == nil {
return nil, ErrAgentNotFoundForShare
}
_ = callerTenantRole
return agent, nil
}
share, err := s.shareRepo.GetShareByAgentIDForTenant(ctx, tenantID, agentID, tenantID)
if err != nil {
if errors.Is(err, repository.ErrAgentShareNotFound) {
@@ -0,0 +1,91 @@
package service
import (
"context"
"testing"
"github.com/Tencent/WeKnora/internal/agent/tools"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/require"
)
type sharedAgentWebSearchRepo struct {
byIDTenant uint64
byID string
defaultTenant uint64
explicit *types.WebSearchProviderEntity
defaultProvider *types.WebSearchProviderEntity
}
func (r *sharedAgentWebSearchRepo) Create(context.Context, *types.WebSearchProviderEntity) error {
return nil
}
func (r *sharedAgentWebSearchRepo) GetByID(_ context.Context, tenantID uint64, id string) (*types.WebSearchProviderEntity, error) {
r.byIDTenant = tenantID
r.byID = id
return r.explicit, nil
}
func (r *sharedAgentWebSearchRepo) GetDefault(_ context.Context, tenantID uint64) (*types.WebSearchProviderEntity, error) {
r.defaultTenant = tenantID
return r.defaultProvider, nil
}
func (r *sharedAgentWebSearchRepo) List(context.Context, uint64) ([]*types.WebSearchProviderEntity, error) {
return nil, nil
}
func (r *sharedAgentWebSearchRepo) Update(context.Context, *types.WebSearchProviderEntity) error {
return nil
}
func (r *sharedAgentWebSearchRepo) Delete(context.Context, uint64, string) error { return nil }
func (r *sharedAgentWebSearchRepo) ClearDefault(context.Context, uint64, string) error { return nil }
func TestSharedAgentWebSearchReadyUsesSourceWorkspace(t *testing.T) {
repo := &sharedAgentWebSearchRepo{
explicit: &types.WebSearchProviderEntity{ID: "source-provider", TenantID: 42},
}
svc := &agentShareService{webSearchProviderRepo: repo}
agent := &types.CustomAgent{Config: types.CustomAgentConfig{
WebSearchEnabled: true,
WebSearchProviderID: "source-provider",
}}
require.True(t, svc.isAgentWebSearchReady(context.Background(), agent, 42))
require.Equal(t, uint64(42), repo.byIDTenant)
require.Equal(t, "source-provider", repo.byID)
}
func TestSharedAgentWebSearchReadyUsesSourceDefault(t *testing.T) {
repo := &sharedAgentWebSearchRepo{
defaultProvider: &types.WebSearchProviderEntity{ID: "source-default", TenantID: 42, IsDefault: true},
}
svc := &agentShareService{webSearchProviderRepo: repo}
agent := &types.CustomAgent{Config: types.CustomAgentConfig{WebSearchEnabled: true}}
require.True(t, svc.isAgentWebSearchReady(context.Background(), agent, 42))
require.Equal(t, uint64(42), repo.defaultTenant)
}
func TestFilterSharedAgentWriteTools(t *testing.T) {
got := filterSharedAgentWriteTools([]string{
tools.ToolWikiReadPage,
tools.ToolWikiFlagIssue,
tools.ToolWikiWritePage,
tools.ToolWikiReplaceText,
tools.ToolWikiRenamePage,
tools.ToolWikiDeletePage,
tools.ToolWikiReadIssue,
tools.ToolWikiUpdateIssue,
tools.ToolWebSearch,
})
require.Equal(t, []string{
tools.ToolWikiReadPage,
tools.ToolWikiReadIssue,
tools.ToolWebSearch,
}, got)
}
@@ -233,6 +233,7 @@ func (s *sessionService) buildAgentConfig(
RetrieveKBOnlyWhenMentioned: customAgent.Config.RetrieveKBOnlyWhenMentioned,
LLMCallTimeout: customAgent.Config.LLMCallTimeout,
RetainRetrievalHistory: customAgent.Config.RetainRetrievalHistory,
SharedAgentReadOnly: req.SharedAgentReadOnly,
}
// Falls back to global configuration if no specific timeout is set for the agent.
@@ -260,7 +261,7 @@ func (s *sessionService) buildAgentConfig(
// Apply per-turn @Skill / @MCP scope. Each helper narrows the agent's
// whitelist to the mentioned items and records the pinned set used for the
// <must_use> hint, keeping all scope logic in one place per resource type.
isSharedAgent := req.Session != nil && req.Session.TenantID != customAgent.TenantID
isSharedAgent := req.SharedAgentReadOnly
applyPerRequestSkillScope(ctx, agentConfig, customAgent.Config.SkillsSelectionMode, req.SkillNames)
applyPerRequestMCPScope(ctx, agentConfig, customAgent.Config.MCPServices, isSharedAgent, req.MCPServiceIDs)
@@ -150,7 +150,11 @@ func (s *temporaryDocumentService) Create(
return nil, fmt.Errorf("unsafe file name: %w", err)
}
ext := strings.ToLower(filepath.Ext(baseName))
if !s.supportsExtension(ctx, tenantID, ext) {
resourceTenantID := options.ResourceTenantID
if resourceTenantID == 0 {
resourceTenantID = tenantID
}
if !s.supportsExtension(ctx, resourceTenantID, ext) {
return nil, fmt.Errorf("unsupported file type: %s", ext)
}
maxSize := secutils.GetMaxFileSizeMB() * 1024 * 1024
@@ -288,9 +292,15 @@ func (s *temporaryDocumentService) Process(ctx context.Context, task *asynq.Task
if err := s.repo.MarkProcessing(ctx, payload.TenantID, payload.DocumentID, startedAt); err != nil {
return err
}
// Ensure model resolution (VLM/ASR) has a tenant ID in context.
ctx = context.WithValue(ctx, types.TenantIDContextKey, payload.TenantID)
if tenant, tenantErr := s.tenantService.GetTenantByID(ctx, payload.TenantID); tenantErr == nil && tenant != nil {
// The attachment row and file remain scoped to payload.TenantID, while
// parser/model dependencies may belong to a verified shared-agent source.
resourceTenantID := payload.TenantID
var options types.TemporaryDocumentCreateOptions
if json.Unmarshal(document.ProcessingOptions, &options) == nil && options.ResourceTenantID != 0 {
resourceTenantID = options.ResourceTenantID
}
ctx = context.WithValue(ctx, types.TenantIDContextKey, resourceTenantID)
if tenant, tenantErr := s.tenantService.GetTenantByID(ctx, resourceTenantID); tenantErr == nil && tenant != nil {
ctx = context.WithValue(ctx, types.TenantInfoContextKey, tenant)
}
content, images, metadata, parseErr := s.parse(ctx, document)
+9 -6
View File
@@ -188,7 +188,8 @@ func (h *KnowledgeHandler) resolveKnowledgeAndValidateKBAccess(c *gin.Context, k
if h.agentShareService != nil && requiredPermission == types.OrgRoleViewer {
agentID := c.Query("agent_id")
if agentID != "" {
agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, tenantID, callerTenantRole, agentID)
sourceTenantID, _ := strconv.ParseUint(c.Query("agent_source_tenant_id"), 10, 64)
agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, tenantID, callerTenantRole, agentID, sourceTenantID)
if err == nil && agent != nil {
if knowledge.TenantID != agent.TenantID {
return nil, ctx, errors.NewForbiddenError("Permission denied to access this knowledge")
@@ -1384,9 +1385,10 @@ func (h *KnowledgeHandler) PreviewKnowledgeFile(c *gin.Context) {
// GetKnowledgeBatchRequest defines parameters for batch knowledge retrieval
type GetKnowledgeBatchRequest struct {
IDs []string `form:"ids" binding:"required"` // List of knowledge IDs
KBID string `form:"kb_id"` // Optional: scope to this KB (validates access and uses effective tenant for shared KB)
AgentID string `form:"agent_id"` // Optional: when using a shared agent, use agent's tenant for retrieval (validates shared agent access)
IDs []string `form:"ids" binding:"required"` // List of knowledge IDs
KBID string `form:"kb_id"` // Optional: scope to this KB (validates access and uses effective tenant for shared KB)
AgentID string `form:"agent_id"` // Optional: when using a shared agent, use agent's tenant for retrieval (validates shared agent access)
AgentSourceTenantID uint64 `form:"agent_source_tenant_id"` // Optional source selector, verified against the share relation
}
// GetKnowledgeBatch godoc
@@ -1439,7 +1441,7 @@ func (h *KnowledgeHandler) GetKnowledgeBatch(c *gin.Context) {
return
}
callerTenantRole := types.TenantRoleFromContext(ctx)
agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, currentTenantID, callerTenantRole, agentID)
agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, currentTenantID, callerTenantRole, agentID, req.AgentSourceTenantID)
if err != nil || agent == nil {
logger.Warnf(ctx, "GetKnowledgeBatch: invalid or inaccessible shared agent %s: %v", agentID, err)
c.Error(errors.NewForbiddenError("Invalid or inaccessible shared agent").WithDetails(err.Error()))
@@ -1992,7 +1994,8 @@ func (h *KnowledgeHandler) SearchKnowledge(c *gin.Context) {
return
}
callerTenantRole := types.TenantRoleFromContext(ctx)
agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, currentTenantID, callerTenantRole, agentID)
requestedSourceTenantID, _ := strconv.ParseUint(c.Query("agent_source_tenant_id"), 10, 64)
agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, currentTenantID, callerTenantRole, agentID, requestedSourceTenantID)
if err != nil {
if goerrors.Is(err, service.ErrAgentShareNotFound) || goerrors.Is(err, service.ErrAgentSharePermission) || goerrors.Is(err, service.ErrAgentNotFoundForShare) {
c.Error(errors.NewForbiddenError("no permission for this shared agent"))
+4 -2
View File
@@ -474,7 +474,8 @@ func (h *KnowledgeBaseHandler) validateAndGetKnowledgeBase(c *gin.Context) (*typ
currentTenantID := tenantID.(uint64)
agentID := c.Query("agent_id")
if agentID != "" {
agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, currentTenantID, callerTenantRole, agentID)
sourceTenantID, _ := strconv.ParseUint(c.Query("agent_source_tenant_id"), 10, 64)
agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, currentTenantID, callerTenantRole, agentID, sourceTenantID)
if err == nil && agent != nil {
if kb.TenantID != agent.TenantID {
logger.Warnf(ctx, "Shared agent workspace mismatch, KB %s tenant: %d, agent tenant: %d", id, kb.TenantID, agent.TenantID)
@@ -581,7 +582,8 @@ func (h *KnowledgeBaseHandler) ListKnowledgeBases(c *gin.Context) {
return
}
callerTenantRole := types.TenantRoleFromContext(ctx)
agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, currentTenantID, callerTenantRole, agentID)
requestedSourceTenantID, _ := strconv.ParseUint(c.Query("agent_source_tenant_id"), 10, 64)
agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, currentTenantID, callerTenantRole, agentID, requestedSourceTenantID)
if err != nil {
if stderrors.Is(err, service.ErrAgentShareNotFound) || stderrors.Is(err, service.ErrAgentSharePermission) || stderrors.Is(err, service.ErrAgentNotFoundForShare) {
c.Error(apperrors.NewForbiddenError("no permission for this shared agent"))
+44 -23
View File
@@ -43,6 +43,7 @@ type qaRequestContext struct {
webSearchEnabled bool
mentionedItems types.MentionedItems
effectiveTenantID uint64 // when using shared agent, tenant ID for model/KB/MCP resolution; 0 = use context tenant
sharedAgentReadOnly bool // access was granted by a read-only agent share
images []ImageAttachment // Uploaded images with analysis text
userMessageID string // Created user message ID (populated after createUserMessage)
channel string // Source channel: "web", "api", "im", etc.
@@ -62,21 +63,22 @@ type qaRequestContext struct {
func (rc *qaRequestContext) buildQARequest() *types.QARequest {
imageURLs, imageDescription := extractImageURLsAndOCRText(rc.images)
return &types.QARequest{
Session: rc.session,
Query: rc.query,
AssistantMessageID: rc.assistantMessage.ID,
SummaryModelID: rc.summaryModelID,
CustomAgent: rc.customAgent,
KnowledgeBaseIDs: rc.knowledgeBaseIDs,
KnowledgeIDs: rc.knowledgeIDs,
TagScopes: rc.tagScopes,
MCPServiceIDs: rc.mcpServiceIDs,
SkillNames: rc.skillNames,
ImageURLs: imageURLs,
ImageDescription: imageDescription,
UserMessageID: rc.userMessageID,
WebSearchEnabled: rc.webSearchEnabled,
Attachments: rc.attachments,
Session: rc.session,
Query: rc.query,
AssistantMessageID: rc.assistantMessage.ID,
SummaryModelID: rc.summaryModelID,
CustomAgent: rc.customAgent,
SharedAgentReadOnly: rc.sharedAgentReadOnly,
KnowledgeBaseIDs: rc.knowledgeBaseIDs,
KnowledgeIDs: rc.knowledgeIDs,
TagScopes: rc.tagScopes,
MCPServiceIDs: rc.mcpServiceIDs,
SkillNames: rc.skillNames,
ImageURLs: imageURLs,
ImageDescription: imageDescription,
UserMessageID: rc.userMessageID,
WebSearchEnabled: rc.webSearchEnabled,
Attachments: rc.attachments,
}
}
@@ -138,7 +140,10 @@ func (h *Handler) parseQARequest(c *gin.Context, logPrefix string) (*qaRequestCo
}
// Get custom agent if agent_id is provided. Backend resolves shared agent from share relation (no client-provided tenant).
customAgent, effectiveTenantID := h.resolveAgent(ctx, c, request.AgentID)
customAgent, effectiveTenantID, sharedAgentReadOnly := h.resolveAgent(ctx, c, request.AgentID, request.AgentSourceTenantID)
if request.AgentSourceTenantID != 0 && customAgent == nil {
return nil, nil, errors.NewNotFoundError("Shared agent not found")
}
// Merge @mentioned items into knowledge_base_ids and knowledge_ids
kbIDs, knowledgeIDs := mergeKnowledgeTargets(request.KnowledgeBaseIDs, request.KnowledgeIds, request.MentionedItems)
@@ -160,6 +165,7 @@ func (h *Handler) parseQARequest(c *gin.Context, logPrefix string) (*qaRequestCo
); scopedTenantID != 0 {
customAgent = scopedAgent
effectiveTenantID = scopedTenantID
sharedAgentReadOnly = false
}
}
@@ -205,6 +211,10 @@ func (h *Handler) parseQARequest(c *gin.Context, logPrefix string) (*qaRequestCo
}
tenantID := c.GetUint64(types.TenantIDContextKey.String())
attachmentRuntimeCtx := ctx
if effectiveTenantID != 0 {
attachmentRuntimeCtx = context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID)
}
// Use ASR only when the agent has audio upload enabled.
asrModelID := ""
@@ -229,7 +239,7 @@ func (h *Handler) parseQARequest(c *gin.Context, logPrefix string) (*qaRequestCo
}
processed, err := h.attachmentProcessor.ProcessAttachment(
ctx, data, att.FileName, att.FileSize, tenantID, asrModelID,
attachmentRuntimeCtx, data, att.FileName, att.FileSize, tenantID, asrModelID,
)
if err != nil {
errChan <- fmt.Errorf("attachment %d processing failed: %w", idx+1, err)
@@ -341,6 +351,7 @@ func (h *Handler) parseQARequest(c *gin.Context, logPrefix string) (*qaRequestCo
webSearchEnabled: request.WebSearchEnabled,
mentionedItems: convertMentionedItems(request.MentionedItems),
effectiveTenantID: effectiveTenantID,
sharedAgentReadOnly: sharedAgentReadOnly,
images: request.Images,
channel: request.Channel,
attachments: processedAttachments,
@@ -447,9 +458,14 @@ func cloneTagScopes(scopes []types.TagScope) []types.TagScope {
// resolveAgent resolves the custom agent by ID, trying shared agent first, then own agent.
// Returns (nil, 0) if agentID is empty or not found.
func (h *Handler) resolveAgent(ctx context.Context, c *gin.Context, agentID string) (*types.CustomAgent, uint64) {
func (h *Handler) resolveAgent(
ctx context.Context,
c *gin.Context,
agentID string,
sourceTenantID uint64,
) (*types.CustomAgent, uint64, bool) {
if agentID == "" {
return nil, 0
return nil, 0, false
}
logger.Infof(ctx, "Resolving agent, agent ID: %s", secutils.SanitizeForLog(agentID))
@@ -457,21 +473,26 @@ func (h *Handler) resolveAgent(ctx context.Context, c *gin.Context, agentID stri
// Try shared agent first
var customAgent *types.CustomAgent
var effectiveTenantID uint64
var sharedAgentReadOnly bool
userIDVal, _ := c.Get(types.UserIDContextKey.String())
currentTenantID := c.GetUint64(types.TenantIDContextKey.String())
if h.agentShareService != nil && userIDVal != nil && currentTenantID != 0 {
callerTenantRole := types.TenantRoleFromContext(ctx)
agent, err := h.agentShareService.GetSharedAgentForTenant(ctx, currentTenantID, callerTenantRole, agentID)
var agent *types.CustomAgent
var err error
agent, err = h.agentShareService.GetSharedAgentForTenant(ctx, currentTenantID, callerTenantRole, agentID, sourceTenantID)
if err == nil && agent != nil {
effectiveTenantID = agent.TenantID
customAgent = agent
sharedAgentReadOnly = true
logger.Infof(ctx, "Using shared agent: ID=%s, Name=%s, effectiveTenantID=%d (retrieval scope)",
customAgent.ID, customAgent.Name, effectiveTenantID)
}
}
// Fall back to own agent
if customAgent == nil {
// Fall back to an own agent only when no source workspace was requested.
// A rejected shared selector must not silently run a same-ID local builtin.
if customAgent == nil && sourceTenantID == 0 {
agent, err := h.customAgentService.GetAgentByID(ctx, agentID)
if err == nil {
customAgent = agent
@@ -486,7 +507,7 @@ func (h *Handler) resolveAgent(ctx context.Context, c *gin.Context, agentID stri
customAgent.ID, customAgent.Name, customAgent.IsBuiltin, customAgent.Config.AgentMode, effectiveTenantID)
}
return customAgent, effectiveTenantID
return customAgent, effectiveTenantID, sharedAgentReadOnly
}
// mergeKnowledgeTargets merges request KB/knowledge IDs with @mentioned items into deduplicated slices.
@@ -6,6 +6,7 @@ import (
"mime"
"net/http"
"path/filepath"
"strconv"
"strings"
apperrors "github.com/Tencent/WeKnora/internal/errors"
@@ -40,10 +41,16 @@ func (h *Handler) UploadTemporaryDocument(c *gin.Context) {
}
defer file.Close()
agent, _ := h.resolveAgent(ctx, c, c.PostForm("agent_id"))
sourceTenantID, _ := strconv.ParseUint(strings.TrimSpace(c.PostForm("agent_source_tenant_id")), 10, 64)
agent, resourceTenantID, _ := h.resolveAgent(ctx, c, c.PostForm("agent_id"), sourceTenantID)
if sourceTenantID != 0 && agent == nil {
c.Error(apperrors.NewNotFoundError("Shared agent not found"))
return
}
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(fileHeader.Filename)), ".")
options := types.TemporaryDocumentCreateOptions{ParserEngine: strings.TrimSpace(c.PostForm("parser_engine"))}
if agent != nil {
options.ResourceTenantID = resourceTenantID
if len(agent.Config.SupportedFileTypes) > 0 && !containsFileType(agent.Config.SupportedFileTypes, ext) {
c.Error(apperrors.NewBadRequestError("file type is not supported by this agent"))
return
+1
View File
@@ -47,6 +47,7 @@ type CreateKnowledgeQARequest struct {
KnowledgeIds []string `json:"knowledge_ids"` // Selected knowledge ID for this request
AgentEnabled bool `json:"agent_enabled"` // Whether agent mode is enabled for this request
AgentID string `json:"agent_id"` // Selected custom agent ID (backend resolves shared agent and its workspace from share relation)
AgentSourceTenantID uint64 `json:"agent_source_tenant_id,omitempty"` // Optional disambiguator; backend still verifies the share relation
WebSearchEnabled bool `json:"web_search_enabled"` // Whether web search is enabled for this request
SummaryModelID string `json:"summary_model_id"` // Optional summary model ID for this request (overrides session default)
MCPServiceIDs []string `json:"mcp_service_ids"` // Per-request MCP services selected via @mention
+3 -1
View File
@@ -3,6 +3,7 @@ package middleware
import (
"context"
stderrors "errors"
"strconv"
apprepo "github.com/Tencent/WeKnora/internal/application/repository"
"github.com/Tencent/WeKnora/internal/config"
@@ -399,7 +400,8 @@ func resolveSharedAgentAccess(
) *KBAccess {
agentID := c.Query("agent_id")
if agentID != "" {
agent, err := agentShareService.GetSharedAgentForTenant(ctx, tenantID, callerTenantRole, agentID)
sourceTenantID, _ := strconv.ParseUint(c.Query("agent_source_tenant_id"), 10, 64)
agent, err := agentShareService.GetSharedAgentForTenant(ctx, tenantID, callerTenantRole, agentID, sourceTenantID)
if err != nil || agent == nil {
return nil
}
+1 -1
View File
@@ -109,7 +109,7 @@ type stubAgentShareForGuard struct {
kbsViaSomeAgent map[string]bool
}
func (s *stubAgentShareForGuard) GetSharedAgentForTenant(_ context.Context, _ uint64, _ types.TenantRole, agentID string) (*types.CustomAgent, error) {
func (s *stubAgentShareForGuard) GetSharedAgentForTenant(_ context.Context, _ uint64, _ types.TenantRole, agentID string, _ ...uint64) (*types.CustomAgent, error) {
return s.agents[agentID], nil
}
+4
View File
@@ -56,6 +56,10 @@ type AgentConfig struct {
// Per-request @mention pins (runtime only; injected as <must_use> in the user message).
PinnedMCPServiceIDs []string `json:"-"`
PinnedSkillNames []string `json:"-"`
// SharedAgentReadOnly prevents a shared agent from mutating resources in
// its source workspace. It is set from the verified share relation, never
// inferred from a client-provided tenant ID.
SharedAgentReadOnly bool `json:"-"`
// LLM call timeout in seconds (default: 120). Controls the maximum time for a single LLM call.
LLMCallTimeout int `json:"llm_call_timeout,omitempty"`
+3 -2
View File
@@ -96,7 +96,7 @@ type OrganizationRepository interface {
// (not user). The 3-dimension cap is applied inside CheckTenantKBPermission
// and is the canonical permission gate for shared KBs:
//
// effective = min(share.Permission, tenant_org_role, tenant_role_cap)
// effective = min(share.Permission, tenant_org_role, tenant_role_cap)
//
// where tenant_role_cap pins tenant Viewers to OrgRoleViewer regardless
// of the org-level grant — Viewer in your own tenant must always be
@@ -181,7 +181,7 @@ type AgentShareService interface {
// SetSharedAgentDisabledByMe sets whether the current tenant has "disabled" this shared agent for their conversation dropdown (per-tenant preference; will be revisited in a follow-up PR).
SetSharedAgentDisabledByMe(ctx context.Context, tenantID uint64, agentID string, sourceTenantID uint64, disabled bool) error
// GetSharedAgentForTenant returns the shared agent by agentID if the caller's tenant has access; used to resolve KB scope for @ mention.
GetSharedAgentForTenant(ctx context.Context, tenantID uint64, callerTenantRole types.TenantRole, agentID string) (*types.CustomAgent, error)
GetSharedAgentForTenant(ctx context.Context, tenantID uint64, callerTenantRole types.TenantRole, agentID string, sourceTenantID ...uint64) (*types.CustomAgent, error)
// TenantCanAccessKBViaSomeSharedAgent returns true if the caller's tenant has at least one shared agent that can access the given KB (for opening KB detail from "通过智能体可见" list without passing agent_id).
TenantCanAccessKBViaSomeSharedAgent(ctx context.Context, tenantID uint64, callerTenantRole types.TenantRole, kb *types.KnowledgeBase) (bool, error)
GetShare(ctx context.Context, shareID string) (*types.AgentShare, error)
@@ -205,6 +205,7 @@ type AgentShareRepository interface {
ListByOrganization(ctx context.Context, orgID string) ([]*types.AgentShare, error)
ListByOrganizations(ctx context.Context, orgIDs []string) ([]*types.AgentShare, error)
ListSharedAgentsForTenant(ctx context.Context, tenantID uint64) ([]*types.AgentShare, error)
GetShareByAgentIDAndSourceForTenant(ctx context.Context, tenantID uint64, agentID string, sourceTenantID uint64) (*types.AgentShare, error)
CountByOrganizations(ctx context.Context, orgIDs []string) (map[string]int64, error)
// GetShareByAgentIDForTenant returns one share for the given agentID that the tenant can access (tenant in org), excluding source_tenant_id == excludeTenantID.
GetShareByAgentIDForTenant(ctx context.Context, tenantID uint64, agentID string, excludeTenantID uint64) (*types.AgentShare, error)
+4
View File
@@ -265,6 +265,10 @@ type SharedAgentInfo struct {
SharedAt time.Time `json:"shared_at"`
SharedByUserID string `json:"shared_by_user_id,omitempty"`
SharedByUsername string `json:"shared_by_username,omitempty"`
// WebSearchReady is resolved against the source workspace without exposing
// its provider list or credentials. Receivers must not compare the agent's
// provider ID with their own workspace resources.
WebSearchReady bool `json:"web_search_ready"`
// DisabledByMe: current tenant has hidden this shared agent from their conversation dropdown (per-user preference)
DisabledByMe bool `json:"disabled_by_me"`
}
+17 -16
View File
@@ -4,20 +4,21 @@ package types
// replacing the previous 14-parameter method signatures.
// EventBus is passed separately to avoid circular dependency with the event package.
type QARequest struct {
Session *Session // The conversation session
Query string // User query text
AssistantMessageID string // Pre-created assistant message ID
SummaryModelID string // Optional model override; empty = use agent/KB default
CustomAgent *CustomAgent // Optional custom agent for config override
KnowledgeBaseIDs []string // Knowledge base IDs to search (from request + @mentions)
KnowledgeIDs []string // Specific knowledge (file) IDs to search
TagScopes []TagScope // Tag-constrained KB scopes from @mentions
MCPServiceIDs []string // Per-request MCP service IDs from @mentions
SkillNames []string // Per-request preloaded skill names from @mentions
ImageURLs []string // Image URLs for multimodal input
ImageDescription string // VLM-generated image description (fallback for non-vision models)
UserMessageID string // Created user message ID
WebSearchEnabled bool // Whether web search is enabled for this request
QuotedContext string // Quoted message content from IM quote-reply (appended at LLM prompt stage, not used for retrieval)
Attachments MessageAttachments // File attachments (processed and ready for prompt injection)
Session *Session // The conversation session
Query string // User query text
AssistantMessageID string // Pre-created assistant message ID
SummaryModelID string // Optional model override; empty = use agent/KB default
CustomAgent *CustomAgent // Optional custom agent for config override
SharedAgentReadOnly bool // True only when access came from an agent share; source-workspace writes are forbidden
KnowledgeBaseIDs []string // Knowledge base IDs to search (from request + @mentions)
KnowledgeIDs []string // Specific knowledge (file) IDs to search
TagScopes []TagScope // Tag-constrained KB scopes from @mentions
MCPServiceIDs []string // Per-request MCP service IDs from @mentions
SkillNames []string // Per-request preloaded skill names from @mentions
ImageURLs []string // Image URLs for multimodal input
ImageDescription string // VLM-generated image description (fallback for non-vision models)
UserMessageID string // Created user message ID
WebSearchEnabled bool // Whether web search is enabled for this request
QuotedContext string // Quoted message content from IM quote-reply (appended at LLM prompt stage, not used for retrieval)
Attachments MessageAttachments // File attachments (processed and ready for prompt injection)
}
+5 -2
View File
@@ -92,8 +92,11 @@ type TemporaryDocumentTaskPayload struct {
}
type TemporaryDocumentCreateOptions struct {
ASRModelID string `json:"asr_model_id,omitempty"`
ParserEngine string `json:"parser_engine,omitempty"`
// ResourceTenantID is the verified agent source workspace used to resolve
// parser/model dependencies. The document itself remains owned by TenantID.
ResourceTenantID uint64 `json:"resource_tenant_id,omitempty"`
ASRModelID string `json:"asr_model_id,omitempty"`
ParserEngine string `json:"parser_engine,omitempty"`
// VLMModelID enables image understanding (caption/OCR) during async parse.
// Images use it to produce real text content; scanned/image-only documents
// use it as an OCR fallback when ImageUnderstanding is on.