mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-29 02:27:35 +08:00
Harden VFS provenance and resource writes
This commit is contained in:
@@ -418,7 +418,15 @@ export const githubConnector: ConnectorConfig = {
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return { valid: false, error: `Cannot access repository: ${response.status}` }
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
response.status === 401
|
||||
? 'Cannot access repository: 401 — the token was rejected (invalid, expired, or not a real token). Pass a valid PAT or a {{ENV_VAR}} reference to one.'
|
||||
: response.status === 403
|
||||
? 'Cannot access repository: 403 — the token lacks access to this repository (missing repo scope, or fine-grained token not granted to it).'
|
||||
: `Cannot access repository: ${response.status}`,
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
|
||||
@@ -640,7 +640,10 @@ export const slackConnector: ConnectorConfig = {
|
||||
VALIDATE_RETRY_OPTIONS
|
||||
)
|
||||
} catch {
|
||||
return { valid: false, error: `Channel not found: ${input}` }
|
||||
return {
|
||||
valid: false,
|
||||
error: `Channel not found: ${input}. The selected credential cannot see it — it may belong to a different Slack workspace, or the channel is private and the connected user/bot is not a member.`,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
nameLookups.push(trimmed)
|
||||
@@ -684,7 +687,10 @@ export const slackConnector: ConnectorConfig = {
|
||||
} while (cursor)
|
||||
|
||||
const missing = Array.from(remaining)
|
||||
return { valid: false, error: `Channel(s) not found: ${missing.join(', ')}` }
|
||||
return {
|
||||
valid: false,
|
||||
error: `Channel(s) not found: ${missing.join(', ')}. The selected credential cannot see them — they may belong to a different Slack workspace, or they are private channels the connected user/bot is not a member of.`,
|
||||
}
|
||||
} catch (error) {
|
||||
const message = toError(error).message || 'Failed to validate configuration'
|
||||
return { valid: false, error: message }
|
||||
|
||||
@@ -2312,8 +2312,19 @@ export const Extensions: ToolCatalogEntry = {
|
||||
parameters: {
|
||||
properties: {
|
||||
request: { description: 'What tool/skill/MCP action is needed.', type: 'string' },
|
||||
sessionId: {
|
||||
description:
|
||||
'Reusable session ID returned by an earlier extensions call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message. Omit it for a new or independent task.',
|
||||
type: 'string',
|
||||
},
|
||||
title: {
|
||||
description:
|
||||
"Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the extensions agent. When resuming with sessionId, copy the registry title unchanged.",
|
||||
maxLength: 120,
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['request'],
|
||||
required: ['request', 'title'],
|
||||
type: 'object',
|
||||
},
|
||||
subagentId: 'agent',
|
||||
@@ -2500,7 +2511,19 @@ export const File: ToolCatalogEntry = {
|
||||
"Optional brief instruction (one short sentence) to scope the task. The agent inherits the full conversation history — do NOT restate or rewrite conversation content, only add scoping the history doesn't convey.",
|
||||
type: 'string',
|
||||
},
|
||||
sessionId: {
|
||||
description:
|
||||
'Reusable session ID returned by an earlier file call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message — the agent resumes from its saved transcript and receives unseen parent conversation messages. Omit it for a new or independent task.',
|
||||
type: 'string',
|
||||
},
|
||||
title: {
|
||||
description:
|
||||
"Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the file agent. When resuming with sessionId, copy the registry title unchanged.",
|
||||
maxLength: 120,
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['title'],
|
||||
type: 'object',
|
||||
},
|
||||
subagentId: 'file',
|
||||
@@ -3198,8 +3221,19 @@ export const Knowledge: ToolCatalogEntry = {
|
||||
parameters: {
|
||||
properties: {
|
||||
request: { description: 'What knowledge base action is needed.', type: 'string' },
|
||||
sessionId: {
|
||||
description:
|
||||
'Reusable session ID returned by an earlier knowledge call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message. Omit it for a new or independent task.',
|
||||
type: 'string',
|
||||
},
|
||||
title: {
|
||||
description:
|
||||
"Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the knowledge agent. When resuming with sessionId, copy the registry title unchanged.",
|
||||
maxLength: 120,
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['request'],
|
||||
required: ['request', 'title'],
|
||||
type: 'object',
|
||||
},
|
||||
subagentId: 'knowledge',
|
||||
@@ -3435,7 +3469,7 @@ export const ManageKnowledgeBase: ToolCatalogEntry = {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
description:
|
||||
'API key for API-key-based connectors (required when connector auth mode is apiKey)',
|
||||
'API key for API-key-based connectors (required when connector auth mode is apiKey). Accepts an environment-variable reference — {{NAME}} — resolved server-side from workspace/user environment variables; a raw key also works.',
|
||||
},
|
||||
chunkingConfig: {
|
||||
type: 'object',
|
||||
@@ -5376,8 +5410,21 @@ export const Table: ToolCatalogEntry = {
|
||||
route: 'subagent',
|
||||
mode: 'async',
|
||||
parameters: {
|
||||
properties: { request: { description: 'What table action is needed.', type: 'string' } },
|
||||
required: ['request'],
|
||||
properties: {
|
||||
request: { description: 'What table action is needed.', type: 'string' },
|
||||
sessionId: {
|
||||
description:
|
||||
'Reusable session ID returned by an earlier table call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message. Omit it for a new or independent task.',
|
||||
type: 'string',
|
||||
},
|
||||
title: {
|
||||
description:
|
||||
"Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the table agent. When resuming with sessionId, copy the registry title unchanged.",
|
||||
maxLength: 120,
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['request', 'title'],
|
||||
type: 'object',
|
||||
},
|
||||
subagentId: 'table',
|
||||
|
||||
@@ -2267,8 +2267,19 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
description: 'What tool/skill/MCP action is needed.',
|
||||
type: 'string',
|
||||
},
|
||||
sessionId: {
|
||||
description:
|
||||
'Reusable session ID returned by an earlier extensions call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message. Omit it for a new or independent task.',
|
||||
type: 'string',
|
||||
},
|
||||
title: {
|
||||
description:
|
||||
"Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the extensions agent. When resuming with sessionId, copy the registry title unchanged.",
|
||||
maxLength: 120,
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['request'],
|
||||
required: ['request', 'title'],
|
||||
type: 'object',
|
||||
},
|
||||
resultSchema: undefined,
|
||||
@@ -2463,7 +2474,19 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
"Optional brief instruction (one short sentence) to scope the task. The agent inherits the full conversation history — do NOT restate or rewrite conversation content, only add scoping the history doesn't convey.",
|
||||
type: 'string',
|
||||
},
|
||||
sessionId: {
|
||||
description:
|
||||
'Reusable session ID returned by an earlier file call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message — the agent resumes from its saved transcript and receives unseen parent conversation messages. Omit it for a new or independent task.',
|
||||
type: 'string',
|
||||
},
|
||||
title: {
|
||||
description:
|
||||
"Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the file agent. When resuming with sessionId, copy the registry title unchanged.",
|
||||
maxLength: 120,
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['title'],
|
||||
type: 'object',
|
||||
},
|
||||
resultSchema: undefined,
|
||||
@@ -3135,8 +3158,19 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
description: 'What knowledge base action is needed.',
|
||||
type: 'string',
|
||||
},
|
||||
sessionId: {
|
||||
description:
|
||||
'Reusable session ID returned by an earlier knowledge call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message. Omit it for a new or independent task.',
|
||||
type: 'string',
|
||||
},
|
||||
title: {
|
||||
description:
|
||||
"Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the knowledge agent. When resuming with sessionId, copy the registry title unchanged.",
|
||||
maxLength: 120,
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['request'],
|
||||
required: ['request', 'title'],
|
||||
type: 'object',
|
||||
},
|
||||
resultSchema: undefined,
|
||||
@@ -3358,7 +3392,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
description:
|
||||
'API key for API-key-based connectors (required when connector auth mode is apiKey)',
|
||||
'API key for API-key-based connectors (required when connector auth mode is apiKey). Accepts an environment-variable reference — {{NAME}} — resolved server-side from workspace/user environment variables; a raw key also works.',
|
||||
},
|
||||
chunkingConfig: {
|
||||
type: 'object',
|
||||
@@ -5281,8 +5315,19 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
description: 'What table action is needed.',
|
||||
type: 'string',
|
||||
},
|
||||
sessionId: {
|
||||
description:
|
||||
'Reusable session ID returned by an earlier table call in this chat. Supply it only on a later user message that continues the same task, and at most once per user message. Omit it for a new or independent task.',
|
||||
type: 'string',
|
||||
},
|
||||
title: {
|
||||
description:
|
||||
"Required private orchestration label (3–8 words) for this session's stable objective. Stored in the request-local, chat-scoped Subagent Registry supplied only to the main orchestrator; not shown to the table agent. When resuming with sessionId, copy the registry title unchanged.",
|
||||
maxLength: 120,
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['request'],
|
||||
required: ['request', 'title'],
|
||||
type: 'object',
|
||||
},
|
||||
resultSchema: undefined,
|
||||
|
||||
@@ -103,7 +103,7 @@ describe('vfs handlers oversize policy', () => {
|
||||
expect(result.error).toContain('context window')
|
||||
})
|
||||
|
||||
it('fails oversized read results from VFS with grep guidance', async () => {
|
||||
it('fails oversized read results from VFS with paging guidance', async () => {
|
||||
const vfs = makeVfs()
|
||||
vfs.readFileContent.mockResolvedValue(null)
|
||||
vfs.read.mockReturnValue({ content: OVERSIZED_INLINE_CONTENT, totalLines: 1 })
|
||||
@@ -112,11 +112,58 @@ describe('vfs handlers oversize policy', () => {
|
||||
const result = await executeVfsRead({ path: 'workflows/My Workflow/state.json' }, GREP_CTX)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('Use grep')
|
||||
expect(result.error).toContain('offset/limit')
|
||||
expect(result.error).toContain('Page it')
|
||||
expect(result.error).toContain('grep')
|
||||
expect(result.error).toContain('context window')
|
||||
})
|
||||
|
||||
it('pages an oversized workspace file when offset/limit are passed', async () => {
|
||||
const vfs = makeVfs()
|
||||
const lines = Array.from({ length: 5000 }, (_, i) => `line ${i} ${'y'.repeat(50)}`)
|
||||
vfs.readFileContent.mockResolvedValue({
|
||||
content: lines.join('\n'),
|
||||
totalLines: lines.length,
|
||||
})
|
||||
getOrMaterializeVFS.mockResolvedValue(vfs)
|
||||
|
||||
const whole = await executeVfsRead({ path: 'files/big.log/content' }, GREP_CTX)
|
||||
expect(whole.success).toBe(false)
|
||||
expect(whole.error).toContain('Page it')
|
||||
|
||||
const paged = await executeVfsRead(
|
||||
{ path: 'files/big.log/content', offset: 10, limit: 5 },
|
||||
GREP_CTX
|
||||
)
|
||||
expect(paged.success).toBe(true)
|
||||
expect((paged.output as { content: string }).content).toBe(lines.slice(10, 15).join('\n'))
|
||||
})
|
||||
|
||||
it('tells the model to reduce limit when the requested window is still oversized', async () => {
|
||||
const vfs = makeVfs()
|
||||
vfs.readFileContent.mockResolvedValue({
|
||||
content: Array.from({ length: 100 }, () => OVERSIZED_INLINE_CONTENT).join('\n'),
|
||||
totalLines: 100,
|
||||
})
|
||||
getOrMaterializeVFS.mockResolvedValue(vfs)
|
||||
|
||||
const result = await executeVfsRead(
|
||||
{ path: 'files/big.log/content', offset: 0, limit: 50 },
|
||||
GREP_CTX
|
||||
)
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('Reduce limit')
|
||||
})
|
||||
|
||||
it('notes an empty file instead of returning bare empty content', async () => {
|
||||
const vfs = makeVfs()
|
||||
vfs.readFileContent.mockResolvedValue({ content: '', totalLines: 0 })
|
||||
getOrMaterializeVFS.mockResolvedValue(vfs)
|
||||
|
||||
const result = await executeVfsRead({ path: 'files/hi.txt/content' }, GREP_CTX)
|
||||
expect(result.success).toBe(true)
|
||||
expect((result.output as { note?: string }).note).toContain('empty')
|
||||
})
|
||||
|
||||
it('fails file-backed oversized read placeholders with original message', async () => {
|
||||
const vfs = makeVfs()
|
||||
vfs.readFileContent.mockResolvedValue(
|
||||
@@ -723,6 +770,26 @@ describe('vfs uploads are opt-in (like recently-deleted/)', () => {
|
||||
expect((broad.output as { files: string[] }).files).not.toContain('uploads/My%20Report.json')
|
||||
})
|
||||
|
||||
it('explains an empty uploads glob instead of returning a bare []', async () => {
|
||||
const vfs = makeVfs()
|
||||
getOrMaterializeVFS.mockResolvedValue(vfs)
|
||||
listChatUploads.mockResolvedValue([])
|
||||
|
||||
const result = await executeVfsGlob({ pattern: 'uploads/*' }, GREP_CTX_CHAT)
|
||||
expect(result.success).toBe(true)
|
||||
expect((result.output as { files: string[]; note?: string }).files).toEqual([])
|
||||
expect((result.output as { note?: string }).note).toContain('no uploads')
|
||||
})
|
||||
|
||||
it('explains an empty user-local glob instead of returning a bare []', async () => {
|
||||
const vfs = makeVfs()
|
||||
getOrMaterializeVFS.mockResolvedValue(vfs)
|
||||
|
||||
const result = await executeVfsGlob({ pattern: 'user-local/**' }, GREP_CTX_CHAT)
|
||||
expect(result.success).toBe(true)
|
||||
expect((result.output as { note?: string }).note).toContain('user-local')
|
||||
})
|
||||
|
||||
it('reads an upload directly, tolerating a spurious /content suffix', async () => {
|
||||
const vfs = makeVfs()
|
||||
getOrMaterializeVFS.mockResolvedValue(vfs)
|
||||
|
||||
@@ -267,6 +267,25 @@ export async function executeVfsGlob(
|
||||
}
|
||||
|
||||
logger.debug('vfs_glob result', { pattern, fileCount: files.length })
|
||||
// A bare [] on a namespace that is legitimately absent reads as "my glob is
|
||||
// wrong". Say why it's empty so the model doesn't retry pattern variants.
|
||||
if (files.length === 0) {
|
||||
if (pattern.startsWith('uploads')) {
|
||||
return {
|
||||
success: true,
|
||||
output: { files, note: 'This chat has no uploads.' },
|
||||
}
|
||||
}
|
||||
if (pattern.startsWith('user-local')) {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
files,
|
||||
note: 'No user-local folder is granted in this chat, so user-local/ is empty.',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
return { success: true, output: { files } }
|
||||
} catch (err) {
|
||||
logger.error('vfs_glob failed', {
|
||||
@@ -336,28 +355,29 @@ export async function executeVfsRead(
|
||||
const uploadResult = uploadEnvelope?.value
|
||||
if (uploadResult) {
|
||||
const isAttachment = hasModelAttachment(uploadResult)
|
||||
if (
|
||||
!isAttachment &&
|
||||
(isOversizedReadPlaceholder(uploadResult) ||
|
||||
serializedResultSize(uploadResult) > TOOL_RESULT_MAX_INLINE_CHARS)
|
||||
) {
|
||||
if (!isAttachment && isOversizedReadPlaceholder(uploadResult)) {
|
||||
// The loader refused to materialize the bytes at all; a window can't help.
|
||||
return { success: false, error: uploadResult.content }
|
||||
}
|
||||
// Window BEFORE the inline-size gate, so offset/limit genuinely page a
|
||||
// large upload instead of the gate rejecting the whole file first.
|
||||
const windowedUpload = applyWindow(uploadResult)
|
||||
if (!isAttachment && serializedResultSize(windowedUpload) > TOOL_RESULT_MAX_INLINE_CHARS) {
|
||||
logger.warn('Upload read result too large', {
|
||||
path,
|
||||
hasAttachment: isAttachment,
|
||||
contentLength: uploadResult.content.length,
|
||||
serializedSize: serializedResultSize(uploadResult),
|
||||
serializedSize: serializedResultSize(windowedUpload),
|
||||
windowed: offset !== undefined || limit !== undefined,
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: isOversizedReadPlaceholder(uploadResult)
|
||||
? uploadResult.content
|
||||
: // Same as the workspace-file branch below: this size gate runs on
|
||||
// the whole upload before any window, so "retry with offset/limit"
|
||||
// would loop. Point at grep scoped to this path instead.
|
||||
`Read result too large to return inline. Grep this single upload instead of reading it — grep({pattern: "...", path: "${path}"}) — because offset/limit do NOT shrink an upload read: the size check runs on the whole file before the window is applied.`,
|
||||
error:
|
||||
offset !== undefined || limit !== undefined
|
||||
? `The requested window is still too large to return inline. Reduce limit (fewer lines per page) — e.g. read({path: "${path}", offset: ${offset ?? 0}, limit: 200}).`
|
||||
: `Read result too large to return inline. Page it — read({path: "${path}", offset: 0, limit: 500}) — or locate the relevant section first with grep({pattern: "...", path: "${path}"}).`,
|
||||
}
|
||||
}
|
||||
const windowedUpload = applyWindow(uploadResult)
|
||||
const provenanceView =
|
||||
offset === undefined && limit === undefined
|
||||
? (uploadEnvelope?.view ?? 'derived')
|
||||
@@ -406,25 +426,33 @@ export async function executeVfsRead(
|
||||
const fileContent = fileEnvelope?.value
|
||||
if (fileContent) {
|
||||
const isAttachment = hasModelAttachment(fileContent)
|
||||
if (!isAttachment && isOversizedReadPlaceholder(fileContent)) {
|
||||
// The loader refused to materialize the bytes at all; a window can't help.
|
||||
return { success: false, error: fileContent.content }
|
||||
}
|
||||
// Window BEFORE the inline-size gate, so offset/limit genuinely page a
|
||||
// large file instead of the gate rejecting the whole file first — the
|
||||
// paging advice in the error below has to actually work.
|
||||
const windowedFileContent = applyWindow(fileContent)
|
||||
if (
|
||||
!isAttachment &&
|
||||
(isOversizedReadPlaceholder(fileContent) ||
|
||||
serializedResultSize(fileContent) > TOOL_RESULT_MAX_INLINE_CHARS)
|
||||
serializedResultSize(windowedFileContent) > TOOL_RESULT_MAX_INLINE_CHARS
|
||||
) {
|
||||
logger.warn('File read result too large', {
|
||||
path,
|
||||
hasAttachment: isAttachment,
|
||||
contentLength: fileContent.content.length,
|
||||
serializedSize: serializedResultSize(fileContent),
|
||||
serializedSize: serializedResultSize(windowedFileContent),
|
||||
windowed: offset !== undefined || limit !== undefined,
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: isOversizedReadPlaceholder(fileContent)
|
||||
? fileContent.content
|
||||
: `Read result too large to return inline. Locate the relevant section first — grep({pattern: \"...\", path: \"${path}\"}) — then page it with read({path: \"${path}\", offset: <line>, limit: <lines>}). Avoid catch-all greps or full-file reads because they waste context window.`,
|
||||
error:
|
||||
offset !== undefined || limit !== undefined
|
||||
? `The requested window is still too large to return inline. Reduce limit (fewer lines per page) — e.g. read({path: "${path}", offset: ${offset ?? 0}, limit: 200}).`
|
||||
: `Read result too large to return inline. Page it — read({path: "${path}", offset: 0, limit: 500}) — or locate the relevant section first with grep({pattern: "...", path: "${path}"}), then read({path: "${path}", offset: <line>, limit: <lines>}). Avoid catch-all greps or full-file reads because they waste context window.`,
|
||||
}
|
||||
}
|
||||
const windowedFileContent = applyWindow(fileContent)
|
||||
const provenanceView =
|
||||
offset === undefined && limit === undefined ? (fileEnvelope?.view ?? 'derived') : 'derived'
|
||||
if (
|
||||
@@ -454,7 +482,11 @@ export async function executeVfsRead(
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
output: windowedFileContent,
|
||||
output:
|
||||
fileContent.content === '' && !isAttachment
|
||||
? // An empty string with no explanation reads as a failed read.
|
||||
{ ...windowedFileContent, note: 'File is empty (0 bytes).' }
|
||||
: windowedFileContent,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,7 +523,9 @@ export async function executeVfsRead(
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'Read result too large to return inline. Use grep with a more specific pattern or narrower path to locate the relevant section, then retry read with offset/limit. Avoid catch-all greps or full-file reads because they waste context window.',
|
||||
offset !== undefined || limit !== undefined
|
||||
? `The requested window is still too large to return inline. Reduce limit (fewer lines per page) — e.g. read({path: "${path}", offset: ${offset ?? 0}, limit: 200}).`
|
||||
: 'Read result too large to return inline. Page it with read({path, offset, limit}), or use grep with a more specific pattern to locate the relevant section first. Avoid catch-all greps or full-file reads because they waste context window.',
|
||||
}
|
||||
}
|
||||
logger.debug('vfs_read result', { path, totalLines: result.totalLines, offset, limit })
|
||||
|
||||
@@ -83,6 +83,12 @@ const {
|
||||
vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({
|
||||
ManageKnowledgeBase: { id: 'manage_knowledge_base' },
|
||||
}))
|
||||
const { mockGetEffectiveDecryptedEnv } = vi.hoisted(() => ({
|
||||
mockGetEffectiveDecryptedEnv: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/environment/utils', () => ({
|
||||
getEffectiveDecryptedEnv: mockGetEffectiveDecryptedEnv,
|
||||
}))
|
||||
vi.mock('@/lib/core/telemetry', () => ({
|
||||
PlatformEvents: {
|
||||
knowledgeBaseCreated: mockKnowledgeBaseCreated,
|
||||
@@ -700,6 +706,70 @@ describe('manage_knowledge_base trusted application delegation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['{{SIM_GITHUB_PAT}}', '$SIM_GITHUB_PAT', 'SIM_GITHUB_PAT'])(
|
||||
'resolves the %s environment reference into the connector API key',
|
||||
async (ref) => {
|
||||
mockGetEffectiveDecryptedEnv.mockResolvedValue({ SIM_GITHUB_PAT: 'ghp_realtoken' })
|
||||
|
||||
const result = await knowledgeBaseServerTool.execute(
|
||||
{
|
||||
operation: 'add_connector',
|
||||
args: { knowledgeBaseId: KNOWLEDGE_BASE.id, connectorType: 'github', apiKey: ref },
|
||||
},
|
||||
BILLED_CONTEXT
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const call = mockCreateKnowledgeConnector.mock.calls.at(-1)?.[0] as {
|
||||
input: { apiKey?: string }
|
||||
}
|
||||
expect(call.input.apiKey).toBe('ghp_realtoken')
|
||||
}
|
||||
)
|
||||
|
||||
it('names the missing variable instead of sending a placeholder upstream', async () => {
|
||||
mockGetEffectiveDecryptedEnv.mockResolvedValue({})
|
||||
|
||||
const result = await knowledgeBaseServerTool.execute(
|
||||
{
|
||||
operation: 'add_connector',
|
||||
args: {
|
||||
knowledgeBaseId: KNOWLEDGE_BASE.id,
|
||||
connectorType: 'github',
|
||||
apiKey: '{{SIM_GITHUB_PAT}}',
|
||||
},
|
||||
},
|
||||
BILLED_CONTEXT
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toContain('SIM_GITHUB_PAT')
|
||||
expect(result.message).toContain('not set')
|
||||
expect(mockCreateKnowledgeConnector).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes a raw API key through untouched', async () => {
|
||||
mockGetEffectiveDecryptedEnv.mockResolvedValue({ SIM_GITHUB_PAT: 'ghp_realtoken' })
|
||||
|
||||
const result = await knowledgeBaseServerTool.execute(
|
||||
{
|
||||
operation: 'add_connector',
|
||||
args: {
|
||||
knowledgeBaseId: KNOWLEDGE_BASE.id,
|
||||
connectorType: 'github',
|
||||
apiKey: 'ghp_literal_key',
|
||||
},
|
||||
},
|
||||
BILLED_CONTEXT
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const call = mockCreateKnowledgeConnector.mock.calls.at(-1)?.[0] as {
|
||||
input: { apiKey?: string }
|
||||
}
|
||||
expect(call.input.apiKey).toBe('ghp_literal_key')
|
||||
})
|
||||
|
||||
it('preserves caller-actionable tag provenance conflicts', async () => {
|
||||
mockDeleteKnowledgeTag.mockRejectedValueOnce(
|
||||
new OrchestrationError(
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '@/lib/copilot/tools/server/base-tool'
|
||||
import { asOrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
|
||||
import { addWorkspaceFilesToKnowledgeBase } from '@/lib/knowledge/application/add-workspace-files'
|
||||
import { MAX_KNOWLEDGE_BATCH_ITEMS } from '@/lib/knowledge/application/batch-policy'
|
||||
import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing'
|
||||
@@ -53,6 +54,42 @@ import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-sec
|
||||
|
||||
const logger = createLogger('KnowledgeBaseServerTool')
|
||||
|
||||
/**
|
||||
* Resolves an environment-variable reference passed as a connector API key.
|
||||
*
|
||||
* Models reference workspace secrets the way workflows do — `{{SIM_GITHUB_PAT}}`
|
||||
* (and, when improvising, `$SIM_GITHUB_PAT` or the bare name). Before this,
|
||||
* the literal placeholder string was sent upstream as the bearer token and the
|
||||
* provider answered 401 — an error that never named the real problem. A raw
|
||||
* key that matches no reference form passes through untouched.
|
||||
*
|
||||
* Returns an error string when a reference names a variable that is not set,
|
||||
* so the model learns the actual fix instead of retrying reference syntaxes.
|
||||
*/
|
||||
async function resolveConnectorApiKey(
|
||||
context: ServerToolContext,
|
||||
workspaceId: string,
|
||||
apiKey: string | undefined
|
||||
): Promise<{ apiKey?: string; error?: string }> {
|
||||
if (!apiKey) return { apiKey }
|
||||
const braced = apiKey.match(/^\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}$/)
|
||||
const dollar = apiKey.match(/^\$([A-Za-z_][A-Za-z0-9_]*)$/)
|
||||
const referencedName = braced?.[1] ?? dollar?.[1]
|
||||
const env = await getEffectiveDecryptedEnv(context.userId, workspaceId)
|
||||
const name = referencedName ?? (Object.hasOwn(env, apiKey) ? apiKey : undefined)
|
||||
if (!name) return { apiKey }
|
||||
const value = env[name]
|
||||
if (value === undefined || value === '') {
|
||||
return {
|
||||
error: `Environment variable "${name}" is not set for this workspace or user, so it cannot be used as the connector API key. Set it first, pass a different {{ENV_VAR}} reference, or pass the raw key.`,
|
||||
}
|
||||
}
|
||||
// Activate the resolved secret on the call's egress registry so any
|
||||
// accidental echo of it (provider error bodies, logs) is redacted.
|
||||
context.resolvedSecretTraceRegistry?.recordResolved(name, value)
|
||||
return { apiKey: value }
|
||||
}
|
||||
|
||||
function requireKnowledgeBillingAttribution(
|
||||
context: ServerToolContext,
|
||||
workspaceId: string
|
||||
@@ -291,6 +328,7 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
|
||||
name: args.name,
|
||||
description: args.description,
|
||||
chunkingConfig: args.chunkingConfig,
|
||||
folderPath: args.folderPath,
|
||||
source: 'agent',
|
||||
}
|
||||
)
|
||||
@@ -929,6 +967,11 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
|
||||
sourceConfig.disabledTagIds = args.disabledTagIds
|
||||
}
|
||||
|
||||
const resolvedKey = await resolveConnectorApiKey(context, workspaceId, args.apiKey)
|
||||
if (resolvedKey.error) {
|
||||
return { success: false, message: resolvedKey.error }
|
||||
}
|
||||
|
||||
assertNotAborted()
|
||||
const { connector, workspaceId: canonicalWorkspaceId } =
|
||||
await executeCopilotKnowledgeUseCase(context, createKnowledgeConnector, {
|
||||
@@ -936,7 +979,7 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
|
||||
assertedWorkspaceId: workspaceId,
|
||||
connectorType: args.connectorType,
|
||||
credentialId: args.credentialId,
|
||||
apiKey: args.apiKey,
|
||||
apiKey: resolvedKey.apiKey,
|
||||
sourceConfig,
|
||||
syncIntervalMinutes: args.syncIntervalMinutes ?? 1440,
|
||||
resolveBillingAttribution: async (billingWorkspaceId) =>
|
||||
|
||||
@@ -194,6 +194,7 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
|
||||
name: args.name,
|
||||
description: args.description,
|
||||
schema: normalizeSchemaSelectColumns(args.schema as TableSchema),
|
||||
folderPath: args.folderPath,
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
|
||||
@@ -40,8 +40,14 @@ function recordSpanError(span: Span, err: unknown) {
|
||||
|
||||
const logger = createLogger('FileReader')
|
||||
|
||||
/** Inline text-read cap — exported so callers can align their own byte-sniff budgets with what read() can actually display. */
|
||||
export const MAX_TEXT_READ_BYTES = 5 * 1024 * 1024 // 5 MB
|
||||
/**
|
||||
* Text-read materialization cap — exported so callers can align their own byte-sniff budgets
|
||||
* with what read() can actually load. This bounds what the server LOADS, not what the model
|
||||
* receives inline: the read handler windows (offset/limit) and inline-size-gates the result,
|
||||
* so a large file is paged rather than sent whole. 20MB keeps multi-MB logs/exports greppable
|
||||
* and pageable while still refusing genuinely unbounded blobs.
|
||||
*/
|
||||
export const MAX_TEXT_READ_BYTES = 20 * 1024 * 1024 // 20 MB
|
||||
/** Vision-attachment cap: what the prepared image must fit into after resizing. */
|
||||
export const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024 // 5 MB
|
||||
// Parseable-document byte cap. Large office/PDF files can still
|
||||
|
||||
@@ -54,14 +54,36 @@ describe('glob', () => {
|
||||
expect(hits).toContain('files/a/meta.json')
|
||||
})
|
||||
|
||||
it('treats braces literally when nobrace is set (matches old builder)', () => {
|
||||
it('expands brace alternatives across path segments', () => {
|
||||
const files = vfsFromEntries([
|
||||
['weird{brace}/x', ''],
|
||||
['weirdA/x', ''],
|
||||
['workflows/Elder/state.json', '{}'],
|
||||
['workflows/Utils/state.json', '{}'],
|
||||
['workflows/Other/state.json', '{}'],
|
||||
])
|
||||
const hits = glob(files, 'workflows/{Elder,Utils}/state.json')
|
||||
expect(hits.sort()).toEqual(['workflows/Elder/state.json', 'workflows/Utils/state.json'])
|
||||
})
|
||||
|
||||
it('expands extension braces', () => {
|
||||
const files = vfsFromEntries([
|
||||
['files/a.png', ''],
|
||||
['files/b.md', ''],
|
||||
['files/c.txt', ''],
|
||||
])
|
||||
const hits = glob(files, 'files/*.{png,md}')
|
||||
expect(hits.sort()).toEqual(['files/a.png', 'files/b.md'])
|
||||
})
|
||||
|
||||
it('expands braces in decoded-form patterns against encoded keys', () => {
|
||||
const files = vfsFromEntries([
|
||||
['workflows/Elder%20v1/state.json', '{}'],
|
||||
['workflows/Elder%20v2/state.json', '{}'],
|
||||
])
|
||||
const hits = glob(files, 'workflows/{Elder v1,Elder v2}/state.json')
|
||||
expect(hits.sort()).toEqual([
|
||||
'workflows/Elder%20v1/state.json',
|
||||
'workflows/Elder%20v2/state.json',
|
||||
])
|
||||
const hits = glob(files, 'weird{brace}/*')
|
||||
expect(hits).toContain('weird{brace}/x')
|
||||
expect(hits).not.toContain('weirdA/x')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -108,8 +108,10 @@ export interface ReadResult {
|
||||
|
||||
/**
|
||||
* Micromatch options tuned to match the prior in-house glob: `bash: false` so a single `*`
|
||||
* never crosses path slashes (required for `files` + star + `meta.json` style paths). `nobrace`
|
||||
* and `noext` disable brace and extglob expansion like the old builder. Uses `micromatch` for
|
||||
* never crosses path slashes (required for `files` + star + `meta.json` style paths). Brace
|
||||
* expansion is ON — `workflows/{A,B}/**` and `*.{png,md}` are the natural way to batch a
|
||||
* glob, and with `nobrace` they silently matched nothing, which reads as "no such files".
|
||||
* `noext` still disables extglob expansion like the old builder. Uses `micromatch` for
|
||||
* well-tested `**` and edge cases instead of a custom `RegExp`.
|
||||
*/
|
||||
/**
|
||||
@@ -130,7 +132,6 @@ const VFS_GLOB_OPTIONS: micromatch.Options = {
|
||||
bash: false,
|
||||
dot: false,
|
||||
windows: false,
|
||||
nobrace: true,
|
||||
noext: true,
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { ToolConfig } from '@/tools/types'
|
||||
import {
|
||||
serializeApiKeyIntegrations,
|
||||
serializeBlockSchema,
|
||||
serializeConnectors,
|
||||
serializeCredentials,
|
||||
serializeDeployments,
|
||||
serializeFileMeta,
|
||||
@@ -528,3 +529,59 @@ describe('serializeCredentials — type distinguishes reconnect flow', () => {
|
||||
expect(json[0].type).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('serializeConnectors — cloneable references, never key material', () => {
|
||||
const now = new Date('2026-08-14T00:00:00.000Z')
|
||||
|
||||
it('exposes credentialId and sourceConfig so a connector can be recreated', () => {
|
||||
const json = JSON.parse(
|
||||
serializeConnectors([
|
||||
{
|
||||
id: 'conn-1',
|
||||
connectorType: 'slack',
|
||||
status: 'active',
|
||||
syncMode: 'incremental',
|
||||
syncIntervalMinutes: 1440,
|
||||
credentialId: 'cred-42',
|
||||
sourceConfig: { channel: 'eng-help', maxMessages: '500' },
|
||||
lastSyncAt: now,
|
||||
lastSyncError: null,
|
||||
lastSyncDocCount: 12,
|
||||
nextSyncAt: null,
|
||||
consecutiveFailures: 0,
|
||||
createdAt: now,
|
||||
},
|
||||
])
|
||||
)
|
||||
expect(json[0]).toMatchObject({
|
||||
id: 'conn-1',
|
||||
credentialId: 'cred-42',
|
||||
sourceConfig: { channel: 'eng-help', maxMessages: '500' },
|
||||
})
|
||||
expect(JSON.stringify(json)).not.toContain('encryptedApiKey')
|
||||
})
|
||||
|
||||
it('omits the credential reference when a connector has none (API-key connectors)', () => {
|
||||
const json = JSON.parse(
|
||||
serializeConnectors([
|
||||
{
|
||||
id: 'conn-2',
|
||||
connectorType: 'github',
|
||||
status: 'active',
|
||||
syncMode: 'incremental',
|
||||
syncIntervalMinutes: 1440,
|
||||
credentialId: null,
|
||||
sourceConfig: { repository: 'simstudioai/sim', branch: 'staging' },
|
||||
lastSyncAt: null,
|
||||
lastSyncError: null,
|
||||
lastSyncDocCount: null,
|
||||
nextSyncAt: null,
|
||||
consecutiveFailures: 0,
|
||||
createdAt: now,
|
||||
},
|
||||
])
|
||||
)
|
||||
expect(json[0].credentialId).toBeUndefined()
|
||||
expect(json[0].sourceConfig).toMatchObject({ repository: 'simstudioai/sim' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -313,7 +313,11 @@ export function serializeDocuments(
|
||||
|
||||
/**
|
||||
* Serialize KB connectors for VFS knowledgebases/{name}/connectors.json.
|
||||
* Shows connector type, sync status, and schedule — NOT credentials or source config.
|
||||
* Shows connector type, sync status, schedule, the credential REFERENCE
|
||||
* (an opaque id — never key material; API keys stay encrypted and are never
|
||||
* serialized), and the source config (repo/branch/channels). The last two are
|
||||
* what make a connector cloneable: without them, recreating a working
|
||||
* connector on a new KB meant guessing both the credential and the channels.
|
||||
*/
|
||||
export function serializeConnectors(
|
||||
connectors: Array<{
|
||||
@@ -322,6 +326,8 @@ export function serializeConnectors(
|
||||
status: string
|
||||
syncMode: string
|
||||
syncIntervalMinutes: number
|
||||
credentialId?: string | null
|
||||
sourceConfig?: unknown
|
||||
lastSyncAt: Date | null
|
||||
lastSyncError: string | null
|
||||
lastSyncDocCount: number | null
|
||||
@@ -337,6 +343,8 @@ export function serializeConnectors(
|
||||
status: c.status,
|
||||
syncMode: c.syncMode,
|
||||
syncIntervalMinutes: c.syncIntervalMinutes,
|
||||
credentialId: c.credentialId ?? undefined,
|
||||
sourceConfig: c.sourceConfig ?? undefined,
|
||||
lastSyncAt: c.lastSyncAt?.toISOString(),
|
||||
lastSyncError: c.lastSyncError || undefined,
|
||||
lastSyncDocCount: c.lastSyncDocCount ?? undefined,
|
||||
|
||||
@@ -34,6 +34,7 @@ vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => (
|
||||
}))
|
||||
|
||||
import { WorkspaceVFS } from '@/lib/copilot/vfs/workspace-vfs'
|
||||
import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
|
||||
|
||||
const MAX_DOC_READ_INPUT_BYTES = 50 * 1024 * 1024
|
||||
const MAX_DOCUMENT_PREVIEW_CODE_BYTES = 1024 * 1024
|
||||
@@ -164,6 +165,57 @@ describe('WorkspaceVFS lazy grep resilience', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkspaceVFS oversized content reads', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function arrangeOversizedContentRead() {
|
||||
const record = {
|
||||
id: 'file-big',
|
||||
workspaceId: 'ws-1',
|
||||
name: 'big.tsv',
|
||||
key: 'big.tsv',
|
||||
path: '/api/files/serve/big.tsv',
|
||||
size: 7_500_000,
|
||||
type: 'text/tab-separated-values',
|
||||
uploadedBy: 'user-1',
|
||||
deletedAt: null,
|
||||
uploadedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
storageContext: 'workspace' as const,
|
||||
}
|
||||
listAllWorkspaceFilesExecute.mockResolvedValue({ files: [record] })
|
||||
findWorkspaceFileRecord.mockReturnValue(record)
|
||||
readWorkspaceFileContentExecute.mockRejectedValue(
|
||||
new PayloadSizeLimitError({ label: 'Workspace file', maxBytes: 20_971_520 })
|
||||
)
|
||||
|
||||
const vfs = new WorkspaceVFS({ kind: 'session', userId: 'user-1', sessionId: 'session-1' })
|
||||
Object.assign(vfs, { _workspaceId: 'ws-1' })
|
||||
const internals = vfs as unknown as { files: Map<string, string> }
|
||||
internals.files.set('files/big.tsv', '')
|
||||
return vfs
|
||||
}
|
||||
|
||||
it('answers a cap breach with an oversized placeholder, not "not found"', async () => {
|
||||
const vfs = arrangeOversizedContentRead()
|
||||
|
||||
const result = await vfs.readFileContent('files/big.tsv/content')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result).toMatchObject({ placeholder: 'oversized' })
|
||||
expect(result?.content).toContain('File too large')
|
||||
expect(result?.content).toContain('big.tsv')
|
||||
})
|
||||
|
||||
it('reports a cap breach honestly for grep instead of "content not found"', async () => {
|
||||
const vfs = arrangeOversizedContentRead()
|
||||
|
||||
await expect(vfs.grepFile('files/big.tsv', 'needle')).rejects.toThrow(/too large to search/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkspaceVFS decoded-equivalent resolution', () => {
|
||||
it('resolves a decoded path to its single encoded twin and rejects ambiguity', () => {
|
||||
const vfs = new WorkspaceVFS({ kind: 'session', userId: 'user-1', sessionId: 'session-1' })
|
||||
|
||||
@@ -100,6 +100,7 @@ import {
|
||||
isDocSandboxEnabled,
|
||||
isHosted,
|
||||
} from '@/lib/core/config/env-flags'
|
||||
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
|
||||
import {
|
||||
getAccessibleEnvCredentials,
|
||||
getAccessibleOAuthCredentials,
|
||||
@@ -1061,6 +1062,9 @@ export class WorkspaceVFS {
|
||||
if (!result) {
|
||||
throw new ops.WorkspaceFileGrepError(`Workspace file content not found for "${path}".`)
|
||||
}
|
||||
if (result.value.placeholder === 'oversized') {
|
||||
throw new ops.WorkspaceFileGrepError(`File is too large to search: ${result.value.content}`)
|
||||
}
|
||||
|
||||
return {
|
||||
value: ops.grepReadResult(leaf, result.value, pattern, contentPath, options),
|
||||
@@ -1600,6 +1604,8 @@ export class WorkspaceVFS {
|
||||
|
||||
const scope = deletedMatch ? 'archived' : 'active'
|
||||
|
||||
let sizeCappedRecord: WorkspaceFileRecord | undefined
|
||||
let sizeCap = MAX_TEXT_READ_BYTES
|
||||
try {
|
||||
const { files } = await listAllWorkspaceFiles.execute({
|
||||
principal: this.requireFilePrincipal(),
|
||||
@@ -1607,15 +1613,17 @@ export class WorkspaceVFS {
|
||||
})
|
||||
const record = findWorkspaceFileRecord(files, fileReference)
|
||||
if (!record) return null
|
||||
sizeCappedRecord = record
|
||||
sizeCap = isImageFileType(resolveEffectiveMimeType(record.type, record.name))
|
||||
? MAX_IMAGE_SOURCE_BYTES
|
||||
: MAX_TEXT_READ_BYTES
|
||||
const { file, content } = await readWorkspaceFileContent.execute({
|
||||
principal: this.requireFilePrincipal(),
|
||||
input: {
|
||||
fileId: record.id,
|
||||
assertedWorkspaceId: this._workspaceId,
|
||||
includeDeleted: scope === 'archived',
|
||||
maxBytes: isImageFileType(resolveEffectiveMimeType(record.type, record.name))
|
||||
? MAX_IMAGE_SOURCE_BYTES
|
||||
: MAX_TEXT_READ_BYTES,
|
||||
maxBytes: sizeCap,
|
||||
},
|
||||
})
|
||||
const result = await readFileRecord(file, content)
|
||||
@@ -1627,6 +1635,15 @@ export class WorkspaceVFS {
|
||||
)
|
||||
: null
|
||||
} catch (err) {
|
||||
// A cap breach is an answer, not a lookup failure: returning null here
|
||||
// reported multi-MB files as "content not found". The oversized
|
||||
// placeholder tells the model the file exists and why it can't be read.
|
||||
if (isPayloadSizeLimitError(err) && sizeCappedRecord) {
|
||||
return bindWorkspaceFileResult(
|
||||
sizeCappedRecord,
|
||||
readPlaceholder.fileTooLarge(sizeCappedRecord.name, sizeCappedRecord.size ?? 0, sizeCap)
|
||||
)
|
||||
}
|
||||
logger.warn('Failed to list workspace files for readFileContent', {
|
||||
workspaceId: this._workspaceId,
|
||||
path,
|
||||
|
||||
@@ -55,7 +55,10 @@ const logger = createLogger('KnowledgeBaseService')
|
||||
*/
|
||||
export class KnowledgeBaseConflictError extends OrchestrationError {
|
||||
constructor(name: string) {
|
||||
super('conflict', `A knowledge base named "${name}" already exists in this workspace`)
|
||||
super(
|
||||
'conflict',
|
||||
`A knowledge base named "${name}" already exists in this workspace. Names are unique across the whole workspace — folders do not namespace them — so pick a different name, or rename/delete the existing knowledge base first.`
|
||||
)
|
||||
this.name = 'KnowledgeBaseConflictError'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,8 +610,10 @@ export async function getBoundWorkspaceFileSecretProvenance(
|
||||
eq(workspaceFiles.id, identity.fileId),
|
||||
eq(workspaceFiles.key, identity.key),
|
||||
eq(workspaceFiles.workspaceId, workspaceId),
|
||||
eq(workspaceFiles.context, identity.context),
|
||||
isNull(workspaceFiles.deletedAt)
|
||||
eq(workspaceFiles.context, identity.context)
|
||||
// Deliberately no deletedAt filter: `id` alone pins the exact row, and
|
||||
// recently-deleted/ reads are a real surface — excluding soft-deleted
|
||||
// rows made every archived file read as provenance-unknown and refused.
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
Reference in New Issue
Block a user