mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-29 02:27:35 +08:00
Standardize tool environment references
This commit is contained in:
@@ -851,7 +851,7 @@ export class TerminalSession {
|
||||
(pending) => ({
|
||||
command: pending.command,
|
||||
output:
|
||||
'This opened a full-screen interactive program, which now holds the terminal until it exits. terminal_read renders its current screen, so you can watch it: if it is doing work the user is waiting on, keep polling with wait + terminal_read until it finishes, exactly as you would a long command. Type into it with terminal_input and stop it with terminal_kill. The user can also drive it in the panel. terminal_run reports BUSY until it exits.',
|
||||
'This opened a full-screen interactive program, which now holds the terminal until it exits. terminal_read renders its current screen, so you can watch it: if it is doing work the user is waiting on, keep polling with wait + terminal_read until it finishes, exactly as you would a long command. Type into it with terminal_input and stop it with terminal_kill. If it is a pager (less, git log, man — the screen ends with ":" or "(END)"), nothing more is coming: exit it by sending terminal_input text "q"; terminal_kill delivers Ctrl-C, which a pager ignores. The user can also drive it in the panel. terminal_run reports BUSY until it exits.',
|
||||
status: 'interactive',
|
||||
exitCode: null,
|
||||
durationMs: Date.now() - pending.startedAt,
|
||||
|
||||
@@ -18,8 +18,16 @@ vi.mock('@/lib/copilot/environment-context', () => ({
|
||||
prepareCopilotEnvironmentContext: mockPrepareEnvironmentContext,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/tools/registry/server-tool-adapter', () => ({
|
||||
createServerToolHandler: () => mockHandler,
|
||||
vi.mock('@/lib/copilot/tool-executor', () => ({
|
||||
ensureHandlersRegistered: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/tool-executor/executor', () => ({
|
||||
executeTool: (
|
||||
_toolName: string,
|
||||
params: Record<string, unknown>,
|
||||
context: Record<string, unknown>
|
||||
) => mockHandler(params, context),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/request/tools/resources', () => ({
|
||||
|
||||
+2
-2
@@ -591,9 +591,9 @@ describe('completed tool titles', () => {
|
||||
expect(failures).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps present tense while executing and on error', () => {
|
||||
it('keeps present tense while executing; failed rows say so', () => {
|
||||
expect(firstToolTitle([queryLogsCall('executing')])).toBe('Querying logs')
|
||||
expect(firstToolTitle([queryLogsCall('error')])).toBe('Querying logs')
|
||||
expect(firstToolTitle([queryLogsCall('error')])).toBe('Failed querying logs')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -212,7 +212,10 @@ export const airtableConnector: ConnectorConfig = {
|
||||
if (response.status === 403) {
|
||||
return { valid: false, error: 'Access denied. Check your Airtable permissions.' }
|
||||
}
|
||||
return { valid: false, error: `Airtable API error: ${response.status} - ${errorText}` }
|
||||
return {
|
||||
valid: false,
|
||||
error: `Airtable API error: ${response.status} — 401 means an invalid PAT (or an unresolved {{ENV_VAR}} placeholder); 403 means the PAT has no access to base "${baseId}". Detail: ${errorText}`,
|
||||
}
|
||||
}
|
||||
|
||||
const viewId = sourceConfig.viewId as string | undefined
|
||||
@@ -229,7 +232,10 @@ export const airtableConnector: ConnectorConfig = {
|
||||
VALIDATE_RETRY_OPTIONS
|
||||
)
|
||||
if (!viewResponse.ok) {
|
||||
return { valid: false, error: `View "${viewId}" not found in table "${tableIdOrName}"` }
|
||||
return {
|
||||
valid: false,
|
||||
error: `View "${viewId}" not found in table "${tableIdOrName}" — or the PAT lacks access to it.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -493,7 +493,10 @@ export const confluenceConnector: ConnectorConfig = {
|
||||
VALIDATE_RETRY_OPTIONS
|
||||
)
|
||||
if (!response.ok) {
|
||||
return { valid: false, error: `Failed to validate spaces: ${response.status}` }
|
||||
return {
|
||||
valid: false,
|
||||
error: `Failed to list Confluence spaces: ${response.status} — 401/403 means the credential lacks space-read scope on this site; 404 means the domain is wrong.`,
|
||||
}
|
||||
}
|
||||
const data = await response.json()
|
||||
const results = (data.results as Array<Record<string, unknown>> | undefined) ?? []
|
||||
@@ -502,7 +505,7 @@ export const confluenceConnector: ConnectorConfig = {
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Space${missing.length > 1 ? 's' : ''} not found: ${missing.join(', ')}`,
|
||||
error: `Space${missing.length > 1 ? 's' : ''} not found: ${missing.join(', ')} — the credential may not see them; they may be in another Atlassian site or restricted spaces the connected user is not a member of.`,
|
||||
}
|
||||
}
|
||||
return { valid: true }
|
||||
|
||||
@@ -272,10 +272,17 @@ export const discordConnector: ConnectorConfig = {
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, 'Failed to validate configuration')
|
||||
if (message.includes('401') || message.includes('403')) {
|
||||
return { valid: false, error: 'Invalid bot token or missing permissions for this channel' }
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
'Discord rejected the request (401/403) — the bot token is invalid, or the bot lacks access to this channel (invite the bot to the server/channel and grant Read Message History).',
|
||||
}
|
||||
}
|
||||
if (message.includes('404')) {
|
||||
return { valid: false, error: `Channel not found: ${channelId}` }
|
||||
return {
|
||||
valid: false,
|
||||
error: `Channel not found: ${channelId}. The bot cannot see it — invite the bot to that server/channel, or check the channel id.`,
|
||||
}
|
||||
}
|
||||
return { valid: false, error: message }
|
||||
}
|
||||
|
||||
@@ -968,8 +968,18 @@ export const gitlabConnector: ConnectorConfig = {
|
||||
if (response.status === 404) {
|
||||
return { valid: false, error: `Project "${project}" not found on ${host}` }
|
||||
}
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: 'Invalid token or insufficient permissions' }
|
||||
if (response.status === 401) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
'GitLab rejected the token (401) — it is invalid, expired, or an unresolved {{ENV_VAR}} placeholder. Pass a valid token or a {{ENV_VAR}} reference to one.',
|
||||
}
|
||||
}
|
||||
if (response.status === 403) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `GitLab token lacks access (403) — it needs read_api/read_repository on "${project}".`,
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
return { valid: false, error: `Cannot access project: ${response.status}` }
|
||||
|
||||
@@ -358,7 +358,7 @@ export const googleDriveConnector: ConnectorConfig = {
|
||||
}
|
||||
return {
|
||||
valid: false,
|
||||
error: `Failed to access folder "${folderId}": ${response.status}`,
|
||||
error: `Failed to access folder "${folderId}": ${response.status} — 403 means the folder exists but is not shared with the connected Google account.`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,7 +383,10 @@ export const googleDriveConnector: ConnectorConfig = {
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
return { valid: false, error: `Failed to access Google Drive: ${response.status}` }
|
||||
return {
|
||||
valid: false,
|
||||
error: `Failed to access Google Drive: ${response.status} — 401 means the token expired (reconnect the Google credential); 403 usually means a missing Drive scope.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -348,7 +348,10 @@ export const microsoftTeamsConnector: ConnectorConfig = {
|
||||
for (const channelInput of channelInputs) {
|
||||
const channel = await resolveChannel(accessToken, teamId, channelInput)
|
||||
if (!channel) {
|
||||
return { valid: false, error: `Channel not found: ${channelInput}` }
|
||||
return {
|
||||
valid: false,
|
||||
error: `Channel not found: ${channelInput}. The connected account cannot see it — it may be in a different team/tenant, or a private channel the user is not a member of.`,
|
||||
}
|
||||
}
|
||||
|
||||
// Verify we can read messages by fetching a single message
|
||||
|
||||
@@ -282,7 +282,7 @@ export const notionConnector: ConnectorConfig = {
|
||||
if (!response.ok) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Cannot access database ${databaseId}: ${response.status}`,
|
||||
error: `Cannot access database ${databaseId}: ${response.status} — 401 means a rejected token; 404 usually means the database is not shared with this Notion integration (share it from the page's "Connections" menu).`,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -300,7 +300,10 @@ export const notionConnector: ConnectorConfig = {
|
||||
VALIDATE_RETRY_OPTIONS
|
||||
)
|
||||
if (!response.ok) {
|
||||
return { valid: false, error: `Cannot access page: ${response.status}` }
|
||||
return {
|
||||
valid: false,
|
||||
error: `Cannot access page ${rootPageId}: ${response.status} — the page is likely not shared with this Notion integration; add it under the page's "Connections" menu.`,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Workspace scope — just verify token works
|
||||
|
||||
@@ -3482,7 +3482,7 @@ export const ManageKnowledgeBase: ToolCatalogEntry = {
|
||||
},
|
||||
minSize: {
|
||||
type: 'number',
|
||||
description: 'Minimum chunk size (1-2000, default: 1)',
|
||||
description: 'Minimum chunk size (1-2000, default: 100)',
|
||||
default: 1,
|
||||
},
|
||||
overlap: {
|
||||
@@ -3604,7 +3604,7 @@ export const ManageKnowledgeBase: ToolCatalogEntry = {
|
||||
},
|
||||
topK: {
|
||||
type: 'number',
|
||||
description: 'Number of results to return (1-50, default: 5)',
|
||||
description: 'Number of results to return (1-100, default: 5)',
|
||||
default: 5,
|
||||
},
|
||||
workspaceId: {
|
||||
@@ -3672,7 +3672,8 @@ export const ManageMcpConnection: ToolCatalogEntry = {
|
||||
},
|
||||
headers: {
|
||||
type: 'object',
|
||||
description: 'Optional HTTP headers to send with requests (key-value pairs)',
|
||||
description:
|
||||
'Optional HTTP headers to send with requests (key-value pairs). Values accept {{ENV_VAR}} references, resolved per-user at connect time — prefer them over pasting raw tokens.',
|
||||
},
|
||||
name: { type: 'string', description: 'Display name for the MCP server' },
|
||||
timeout: {
|
||||
@@ -4371,7 +4372,7 @@ export const QueryUserTable: ToolCatalogEntry = {
|
||||
limit: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor).',
|
||||
'Maximum rows per page for query_rows (optional, max 1000). Omitting it uses the 1000-row default page — the ENTIRE result is never returned in one call; a non-null nextCursor in the result means more rows exist (continue with cursor). A page may also end early at the byte budget with more remaining.',
|
||||
},
|
||||
order: {
|
||||
type: 'array',
|
||||
@@ -4548,7 +4549,16 @@ export const RestoreResource: ToolCatalogEntry = {
|
||||
type: {
|
||||
type: 'string',
|
||||
description: 'The resource type to restore.',
|
||||
enum: ['workflow', 'table', 'file', 'knowledgebase', 'folder', 'file_folder'],
|
||||
enum: [
|
||||
'workflow',
|
||||
'table',
|
||||
'file',
|
||||
'knowledgebase',
|
||||
'folder',
|
||||
'file_folder',
|
||||
'table_folder',
|
||||
'knowledge_folder',
|
||||
],
|
||||
},
|
||||
},
|
||||
required: ['type', 'id'],
|
||||
|
||||
@@ -3405,7 +3405,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
minSize: {
|
||||
type: 'number',
|
||||
description: 'Minimum chunk size (1-2000, default: 1)',
|
||||
description: 'Minimum chunk size (1-2000, default: 100)',
|
||||
default: 1,
|
||||
},
|
||||
overlap: {
|
||||
@@ -3539,7 +3539,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
topK: {
|
||||
type: 'number',
|
||||
description: 'Number of results to return (1-50, default: 5)',
|
||||
description: 'Number of results to return (1-100, default: 5)',
|
||||
default: 5,
|
||||
},
|
||||
workspaceId: {
|
||||
@@ -3608,7 +3608,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
},
|
||||
headers: {
|
||||
type: 'object',
|
||||
description: 'Optional HTTP headers to send with requests (key-value pairs)',
|
||||
description:
|
||||
'Optional HTTP headers to send with requests (key-value pairs). Values accept {{ENV_VAR}} references, resolved per-user at connect time — prefer them over pasting raw tokens.',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
@@ -4300,7 +4301,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
limit: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Maximum rows per page for query_rows (optional). Omit to fetch the ENTIRE matching result in one response — the call fails if the result exceeds the 5MB budget (narrow with a filter or set a limit). With a limit, a page may end early at the byte budget with more remaining; a non-null nextCursor in the result means more rows exist (continue with cursor).',
|
||||
'Maximum rows per page for query_rows (optional, max 1000). Omitting it uses the 1000-row default page — the ENTIRE result is never returned in one call; a non-null nextCursor in the result means more rows exist (continue with cursor). A page may also end early at the byte budget with more remaining.',
|
||||
},
|
||||
order: {
|
||||
type: 'array',
|
||||
@@ -4497,7 +4498,16 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
|
||||
type: {
|
||||
type: 'string',
|
||||
description: 'The resource type to restore.',
|
||||
enum: ['workflow', 'table', 'file', 'knowledgebase', 'folder', 'file_folder'],
|
||||
enum: [
|
||||
'workflow',
|
||||
'table',
|
||||
'file',
|
||||
'knowledgebase',
|
||||
'folder',
|
||||
'file_folder',
|
||||
'table_folder',
|
||||
'knowledge_folder',
|
||||
],
|
||||
},
|
||||
},
|
||||
required: ['type', 'id'],
|
||||
|
||||
@@ -303,6 +303,10 @@ export async function executeDeployCustomBlock(
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
logger.error('Custom block deployment failed', { error })
|
||||
return { success: false, error: 'Custom block deployment failed due to a system error' }
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'Publishing the custom block failed inside Sim; assume it was NOT published. Call get_deployment_status to confirm, retry once, and report the failure if it repeats instead of retrying further.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
messageForCopilotWorkflowError,
|
||||
} from '@/lib/copilot/application/execute-workflow-use-case'
|
||||
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
|
||||
import { resolveEnvReferenceSecretArg } from '@/lib/copilot/tools/server/env-reference'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import {
|
||||
@@ -357,6 +358,19 @@ export async function executeDeployChat(
|
||||
}
|
||||
}
|
||||
|
||||
// "Use the password in {{CHAT_PW}}" arrives as the literal reference —
|
||||
// resolve it, or the placeholder string becomes the chat's real password.
|
||||
const resolvedPassword = await resolveEnvReferenceSecretArg({
|
||||
userId: context.userId,
|
||||
workspaceId: context.workspaceId,
|
||||
value: params.password ?? undefined,
|
||||
argName: 'password',
|
||||
registry: context.resolvedSecretTraceRegistry,
|
||||
})
|
||||
if (resolvedPassword.error) {
|
||||
return { success: false, error: resolvedPassword.error }
|
||||
}
|
||||
|
||||
const result = await executeCopilotWorkflowUseCase(context, deployWorkflowChat, {
|
||||
workflowId,
|
||||
assertedWorkspaceId: context.workspaceId,
|
||||
@@ -371,7 +385,7 @@ export async function executeDeployChat(
|
||||
imageUrl: params.customizations?.imageUrl ?? params.customizations?.iconUrl,
|
||||
},
|
||||
authType: params.authType,
|
||||
password: params.password,
|
||||
password: resolvedPassword.value,
|
||||
allowedEmails: params.allowedEmails,
|
||||
outputConfigs: params.outputConfigs,
|
||||
includeThinking: params.includeThinking,
|
||||
|
||||
@@ -636,6 +636,16 @@ export async function executeFunctionExecute(
|
||||
'internalSandboxProfile',
|
||||
PRIVATE_SECRET_PROVENANCE_FIELD,
|
||||
])
|
||||
// The copilot tool doc promises `timeout` in SECONDS ("Sim converts to
|
||||
// milliseconds", default 10, cap 300); the underlying function tool takes
|
||||
// MILLISECONDS. Nothing converted, so `timeout: 120` armed a 120ms abort.
|
||||
// Values ≤ 600 are read as seconds; larger values are assumed to already be
|
||||
// milliseconds (a model habit worth tolerating). Both clamp to the 300s cap.
|
||||
if (typeof enrichedParams.timeout === 'number' && Number.isFinite(enrichedParams.timeout)) {
|
||||
const raw = enrichedParams.timeout
|
||||
const ms = raw <= 600 ? raw * 1000 : raw
|
||||
enrichedParams.timeout = Math.min(Math.max(ms, 1000), 300_000)
|
||||
}
|
||||
if (params.sandboxId !== undefined) {
|
||||
if (typeof params.sandboxId !== 'string' || !params.sandboxId.trim()) {
|
||||
throw new Error('sandboxId must be a non-empty Sim sandbox id')
|
||||
|
||||
@@ -250,7 +250,7 @@ export async function executeManageCustomTool(
|
||||
error:
|
||||
classified && classified.code !== 'internal'
|
||||
? classified.message
|
||||
: 'Failed to manage custom tool',
|
||||
: `The ${operation ?? 'custom tool'} operation failed inside Sim. The write may or may not have landed — run operation "list" to check current state before retrying.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ export async function executeManageMcpTool(
|
||||
error:
|
||||
classified && classified.code !== 'internal'
|
||||
? classified.message
|
||||
: 'Failed to manage MCP server',
|
||||
: `The ${operation ?? 'MCP server'} operation failed inside Sim. The write may or may not have landed — run operation "list" to check current state before retrying.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,11 @@ export async function executeManageSandbox(
|
||||
workspaceId,
|
||||
SANDBOX_MUTATION_LIMIT
|
||||
)
|
||||
if (limited) return { success: false, error: 'Rate limit exceeded' }
|
||||
if (limited)
|
||||
return {
|
||||
success: false,
|
||||
error: `Rate limit exceeded for sandbox ${operation} in this workspace — do not retry now; continue with other work or tell the user the limit was hit.`,
|
||||
}
|
||||
|
||||
if (operation === 'add') {
|
||||
const parsed = createSandboxBodySchema.safeParse({
|
||||
|
||||
@@ -66,7 +66,9 @@ async function resolveResource(
|
||||
const wf = await getWorkflowById(item.id)
|
||||
if (!wf) return { error: `No workflow with id "${item.id}".` }
|
||||
if (context.workspaceId && wf.workspaceId !== context.workspaceId)
|
||||
return { error: `Workflow not found in the current workspace.` }
|
||||
return {
|
||||
error: `Workflow "${item.id}" is not in the current workspace — run glob("workflows/*/meta.json") for workflows you can reference.`,
|
||||
}
|
||||
resourceId = wf.id
|
||||
title = wf.name
|
||||
}
|
||||
@@ -75,7 +77,9 @@ async function resolveResource(
|
||||
const tbl = await getTableById(item.id)
|
||||
if (!tbl) return { error: `No table with id "${item.id}".` }
|
||||
if (context.workspaceId && tbl.workspaceId !== context.workspaceId)
|
||||
return { error: `Table not found in the current workspace.` }
|
||||
return {
|
||||
error: `Table "${item.id}" is not in the current workspace — run glob("tables/*") for tables you can reference.`,
|
||||
}
|
||||
resourceId = tbl.id
|
||||
title = tbl.name
|
||||
if (item.view) {
|
||||
@@ -113,7 +117,9 @@ async function resolveResource(
|
||||
classified?.code === 'forbidden' ||
|
||||
classified?.code === 'unauthorized'
|
||||
) {
|
||||
return { error: 'Knowledge base not found in the current workspace.' }
|
||||
return {
|
||||
error: `Knowledge base "${item.id}" is not readable in the current workspace — it does not exist here or you lack access. Run glob("knowledgebases/*") for ids you can open.`,
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -125,7 +131,9 @@ async function resolveResource(
|
||||
const logRecord = await getLogById(item.id)
|
||||
if (!logRecord) return { error: `No log with id "${item.id}".` }
|
||||
if (context.workspaceId && logRecord.workspaceId !== context.workspaceId)
|
||||
return { error: `Log not found in the current workspace.` }
|
||||
return {
|
||||
error: `Log "${item.id}" is not in the current workspace — use query_logs to find valid execution ids.`,
|
||||
}
|
||||
resourceId = logRecord.id
|
||||
const workflowName = logRecord.workflowName ?? 'Unknown Workflow'
|
||||
const timestamp = logRecord.startedAt.toLocaleString('en-US', {
|
||||
|
||||
@@ -290,7 +290,11 @@ export async function executeVfsMkdir(
|
||||
|
||||
if (top === 'tables' || top === 'knowledgebases') {
|
||||
outcomes.push(
|
||||
folderedOutcomes.get(path) ?? { from: path, kind, error: 'Folder creation failed' }
|
||||
folderedOutcomes.get(path) ?? {
|
||||
from: path,
|
||||
kind,
|
||||
error: `No result came back for "${path}" — the parent path may not exist or the name may collide. Run glob on the parent to confirm, and do not repeat the identical call.`,
|
||||
}
|
||||
)
|
||||
continue
|
||||
}
|
||||
@@ -312,7 +316,7 @@ export async function executeVfsMkdir(
|
||||
fileOutcomes.get(path) ?? {
|
||||
from: path,
|
||||
kind: 'file_folder',
|
||||
error: 'File folder creation failed',
|
||||
error: `No result came back for "${path}" — the parent path may not exist or the name may collide. Run glob on the parent to confirm, and do not repeat the identical call.`,
|
||||
}
|
||||
)
|
||||
} else {
|
||||
@@ -320,7 +324,7 @@ export async function executeVfsMkdir(
|
||||
workflowOutcomes.get(path) ?? {
|
||||
from: path,
|
||||
kind: 'workflow_folder',
|
||||
error: 'Workflow folder creation failed',
|
||||
error: `No result came back for "${path}" — the parent path may not exist or the name may collide. Run glob on the parent to confirm, and do not repeat the identical call.`,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -646,12 +650,16 @@ export async function executeVfsRm(
|
||||
workflowOutcomes.get(path) ?? {
|
||||
from: path,
|
||||
kind: 'workflow',
|
||||
error: 'Workflow deletion failed',
|
||||
error: `No result came back for deleting "${path}" — it may not exist or may already be deleted. Run glob("workflows/*") to confirm before retrying.`,
|
||||
}
|
||||
)
|
||||
} else if (classified.category === 'files') {
|
||||
outcomes.push(
|
||||
fileOutcomes.get(path) ?? { from: path, kind: 'file', error: 'File deletion failed' }
|
||||
fileOutcomes.get(path) ?? {
|
||||
from: path,
|
||||
kind: 'file',
|
||||
error: `No result came back for deleting "${path}" — it may not exist or may already be deleted. Run glob("files/**") to confirm before retrying.`,
|
||||
}
|
||||
)
|
||||
} else {
|
||||
outcomes.push(await removeOne(classified.category, path, context, workspaceId))
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { getEffectiveDecryptedEnv } from '@/lib/environment/utils'
|
||||
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
|
||||
|
||||
/**
|
||||
* Resolves a whole-value `{{ENV_VAR}}` reference in a secret-bearing tool arg.
|
||||
*
|
||||
* Copilot agents never see secret values — the workspace exposes variable
|
||||
* NAMES only — so when a user says "use the password in CHAT_PW" the model
|
||||
* passes `{{CHAT_PW}}`. Without resolution the literal seven-character
|
||||
* placeholder becomes the stored secret and nothing ever errors. Only the
|
||||
* explicit braced form resolves here: unlike API keys, passwords are
|
||||
* free-form strings, so `$NAME`/bare-name heuristics would corrupt real ones.
|
||||
*
|
||||
* Returns an error when the referenced variable is unset so the model learns
|
||||
* the actual fix instead of silently storing the placeholder.
|
||||
*/
|
||||
export async function resolveEnvReferenceSecretArg(args: {
|
||||
userId: string
|
||||
workspaceId?: string
|
||||
value: string | undefined
|
||||
argName: string
|
||||
registry?: ResolvedSecretTraceRegistry
|
||||
}): Promise<{ value?: string; error?: string }> {
|
||||
const { value } = args
|
||||
if (!value) return { value }
|
||||
const braced = value.match(/^\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}$/)
|
||||
if (!braced) return { value }
|
||||
const name = braced[1]
|
||||
const env = await getEffectiveDecryptedEnv(args.userId, args.workspaceId)
|
||||
const resolved = env[name]
|
||||
if (resolved === undefined || resolved === '') {
|
||||
return {
|
||||
error: `Environment variable "${name}" referenced by ${args.argName} is not set for this workspace or user. Set it first, or pass the raw value.`,
|
||||
}
|
||||
}
|
||||
// Activate on the call's egress registry so an accidental echo is redacted.
|
||||
args.registry?.recordResolved(name, resolved)
|
||||
return { value: resolved }
|
||||
}
|
||||
@@ -166,9 +166,17 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool<
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const hint =
|
||||
response.status === 401 || response.status === 403
|
||||
? ' — the URL requires authentication this tool cannot supply; ask for a public or pre-signed link instead'
|
||||
: response.status === 404
|
||||
? ' — the URL does not exist; verify it before retrying'
|
||||
: response.status === 429
|
||||
? ' — the host is rate-limiting; do not retry immediately'
|
||||
: ' — the host rejected the request; retrying the same URL will fail again'
|
||||
return {
|
||||
success: false,
|
||||
message: `Download failed with status ${response.status} ${response.statusText}`,
|
||||
message: `Download failed with status ${response.status} ${response.statusText}${hint}`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { truncate } from '@sim/utils/string'
|
||||
import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case'
|
||||
import {
|
||||
messageForCopilotFileError,
|
||||
@@ -114,7 +115,19 @@ export const editContentServerTool: BaseServerTool<EditContentArgs, EditContentR
|
||||
if (firstIdx === -1) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Patch failed: search string not found in file "${fileRecord.name}"`,
|
||||
message: `Patch failed: search string not found in file "${fileRecord.name}": ${JSON.stringify(truncate(search, 120))}`,
|
||||
}
|
||||
}
|
||||
// The tool doc promises "must match exactly once unless
|
||||
// replaceAll" — enforce it, or an ambiguous search silently
|
||||
// rewrites the first occurrence with a success receipt.
|
||||
if (!intent.edit.replaceAll) {
|
||||
const occurrences = existing.split(search).length - 1
|
||||
if (occurrences > 1) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Patch failed: search string matches ${occurrences} places in "${fileRecord.name}". Add surrounding context to make it unique, or pass replaceAll: true to change every occurrence.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
finalContent = intent.edit.replaceAll
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type BaseServerTool,
|
||||
type ServerToolContext,
|
||||
} from '@/lib/copilot/tools/server/base-tool'
|
||||
import { resolveEnvReferenceSecretArg } from '@/lib/copilot/tools/server/env-reference'
|
||||
import { asOrchestrationError } from '@/lib/core/orchestration/types'
|
||||
import { fileOperations } from '@/lib/workspace-files/application/operations'
|
||||
import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata'
|
||||
@@ -60,7 +61,20 @@ export const shareFileServerTool: BaseServerTool<ShareFileArgs, ShareFileResult>
|
||||
const authType = (params.authType || (nested?.authType as ShareAuthType | undefined)) as
|
||||
| ShareAuthType
|
||||
| undefined
|
||||
const password = params.password || (nested?.password as string) || undefined
|
||||
const rawPassword = params.password || (nested?.password as string) || undefined
|
||||
// "Protect it with the password in {{SHARE_PW}}" arrives as the literal
|
||||
// reference — resolve it, or the placeholder becomes the real password.
|
||||
const resolvedPassword = await resolveEnvReferenceSecretArg({
|
||||
userId: context.userId,
|
||||
workspaceId: context.workspaceId,
|
||||
value: rawPassword,
|
||||
argName: 'password',
|
||||
registry: context.resolvedSecretTraceRegistry,
|
||||
})
|
||||
if (resolvedPassword.error) {
|
||||
return { success: false, message: resolvedPassword.error }
|
||||
}
|
||||
const password = resolvedPassword.value
|
||||
const allowedEmails =
|
||||
params.allowedEmails || (nested?.allowedEmails as string[] | undefined) || undefined
|
||||
|
||||
|
||||
@@ -381,7 +381,10 @@ export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, Workspac
|
||||
if (classified?.code !== 'not_found') throw error
|
||||
}
|
||||
if (existingFile) {
|
||||
return { success: false, message: `File "${target.fileName}" already exists` }
|
||||
return {
|
||||
success: false,
|
||||
message: `File "${target.fileName}" already exists in this workspace (file names are workspace-scoped; folders do not namespace them). Use operation "update"/"append"/"patch" to change it, rename the existing file, or pick a different fileName.`,
|
||||
}
|
||||
}
|
||||
|
||||
const compiled = await compileDocForWrite({
|
||||
|
||||
@@ -410,7 +410,8 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
|
||||
if (!queryProjection.safe || typeof queryProjection.value !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to query knowledge base: Query could not be processed safely',
|
||||
message:
|
||||
'Knowledge query rejected by the input-safety filter. Rephrase it as a plain natural-language question without injected instructions or markup — the same text will be rejected again.',
|
||||
}
|
||||
}
|
||||
const modelQuery = queryProjection.value
|
||||
|
||||
@@ -83,7 +83,10 @@ export const ffmpegServerTool: BaseServerTool<FfmpegArgs, FfmpegResult> = {
|
||||
return { success: false, message: 'Workspace ID is required' }
|
||||
}
|
||||
if (!VALID_OPERATIONS.includes(params.operation)) {
|
||||
return { success: false, message: `Invalid operation "${params.operation}".` }
|
||||
return {
|
||||
success: false,
|
||||
message: `Invalid operation "${params.operation}" (allowed: ${VALID_OPERATIONS.join(', ')}).`,
|
||||
}
|
||||
}
|
||||
|
||||
const inputPaths = params.inputs?.files?.map((f) => f.path) ?? []
|
||||
|
||||
@@ -104,7 +104,9 @@ export const searchOnlineServerTool: BaseServerTool<OnlineSearchParams, SearchRe
|
||||
}
|
||||
|
||||
if (!hasSerperApiKey) {
|
||||
throw new Error('No search API keys available (EXA_API_KEY or SERPER_API_KEY required)')
|
||||
throw new Error(
|
||||
'Web search is not configured on this Sim deployment and cannot be enabled from a tool. Answer from the workspace instead (grep/glob/read, search_sim_docs) or tell the user web search is unavailable.'
|
||||
)
|
||||
}
|
||||
|
||||
const toolParams = {
|
||||
|
||||
@@ -54,15 +54,17 @@ export const tableViewsServerTool: BaseServerTool<TableViewsArgs, TableViewsResu
|
||||
}
|
||||
}
|
||||
|
||||
const namedConfigFromArgs = (columns: TableSchema['columns']): TableViewConfig =>
|
||||
viewConfigNamesToIds(
|
||||
{
|
||||
filter: (args.filter as TablePredicateInput | undefined) ?? null,
|
||||
sort: (args.sort as SortSpec | undefined) ?? null,
|
||||
hiddenColumns: args.hiddenColumns as string[] | undefined,
|
||||
} as TableViewConfig,
|
||||
columns
|
||||
)
|
||||
// Build the patch from only the keys the caller actually sent: the update
|
||||
// path shallow-merges this into the stored config, so including an absent
|
||||
// part as `null` silently wiped a view's saved sort when only the filter
|
||||
// changed (and vice versa) — the doc promises "omit to keep".
|
||||
const namedConfigFromArgs = (columns: TableSchema['columns']): TableViewConfig => {
|
||||
const patch: Record<string, unknown> = {}
|
||||
if (args.filter !== undefined) patch.filter = args.filter as TablePredicateInput | null
|
||||
if (args.sort !== undefined) patch.sort = args.sort as SortSpec | null
|
||||
if (args.hiddenColumns !== undefined) patch.hiddenColumns = args.hiddenColumns as string[]
|
||||
return viewConfigNamesToIds(patch as TableViewConfig, columns)
|
||||
}
|
||||
|
||||
switch (operation) {
|
||||
case 'list_views': {
|
||||
|
||||
@@ -1124,7 +1124,7 @@ export async function validateWorkflowSelectorIds(
|
||||
blockType: selector.blockType,
|
||||
field: selector.fieldName,
|
||||
value: selector.value,
|
||||
error: `Invalid ${selector.selectorType} ID(s): ${result.invalid.join(', ')} - ID(s) do not exist or user doesn't have access${warningInfo}`,
|
||||
error: `Invalid ${selector.selectorType} ID(s): ${result.invalid.join(', ')} — they do not exist in this workspace or you lack access. Discover valid ids first (glob/read the matching workspace resource, e.g. environment/credentials.json, knowledgebases/*/meta.json, tables/*/meta.json) instead of guessing${warningInfo}`,
|
||||
})
|
||||
} else if (result.warning) {
|
||||
// Log warnings that don't have errors (shouldn't happen for credentials but may for other selectors)
|
||||
@@ -1733,7 +1733,7 @@ export async function preValidateCredentialInputs(
|
||||
blockType: credInput.blockType,
|
||||
field: credInput.fieldName,
|
||||
value: credInput.value,
|
||||
error: `Invalid credential ID "${credInput.value}" - credential does not exist or user doesn't have access${warningInfo}`,
|
||||
error: `Invalid credential ID "${credInput.value}" for ${credInput.blockType}.${credInput.fieldName} — the field was removed from the block. Read environment/credentials.json for connected credential ids, or use oauth_get_auth_link (via the auth agent) to connect the provider first; never invent credential ids${warningInfo}`,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -179,12 +179,26 @@ describe('getToolCompletedTitle', () => {
|
||||
expect(getToolCompletedTitle('Custom title from the model')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('projects completed titles only for successful rows', () => {
|
||||
it('projects a terminal tense for every settled row, present tense only while running', () => {
|
||||
expect(getToolStatusDisplayTitle('Comparing workflows', 'success')).toBe('Compared workflows')
|
||||
expect(getToolStatusDisplayTitle('Comparing workflows', 'executing')).toBe(
|
||||
'Comparing workflows'
|
||||
)
|
||||
expect(getToolStatusDisplayTitle('Comparing workflows', 'error')).toBe('Comparing workflows')
|
||||
// An errored row must not read as still running — the frozen present-tense
|
||||
// title ("Searching for X" forever) was reported as a stuck tool call.
|
||||
expect(getToolStatusDisplayTitle('Comparing workflows', 'error')).toBe(
|
||||
'Failed comparing workflows'
|
||||
)
|
||||
expect(getToolStatusDisplayTitle('Searching for admin mentions', 'error')).toBe(
|
||||
'Failed searching for admin mentions'
|
||||
)
|
||||
expect(getToolStatusDisplayTitle('Comparing workflows', 'cancelled')).toBe(
|
||||
'Stopped comparing workflows'
|
||||
)
|
||||
// Non-gerund titles get a prefix rather than a bad rewrite.
|
||||
expect(getToolStatusDisplayTitle('Read recent emails', 'error')).toBe(
|
||||
'Failed: Read recent emails'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1136,11 +1136,36 @@ export function getToolCompletedTitle(title: string): string | undefined {
|
||||
return past + title.slice(firstWord.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a resolved display title for a FAILED tool call. A gerund title
|
||||
* becomes "Failed <gerund>…" ("Searching for X" → "Failed searching for X");
|
||||
* anything else gets a "Failed: " prefix. Without this, an errored row kept
|
||||
* its present-tense activity title verbatim and read as still running.
|
||||
*/
|
||||
export function getToolFailedTitle(title: string): string {
|
||||
const spaceIndex = title.indexOf(' ')
|
||||
const firstWord = spaceIndex === -1 ? title : title.slice(0, spaceIndex)
|
||||
if (COMPLETED_VERB_REWRITES[firstWord]) {
|
||||
return `Failed ${firstWord.charAt(0).toLowerCase()}${firstWord.slice(1)}${title.slice(firstWord.length)}`
|
||||
}
|
||||
return `Failed: ${title}`
|
||||
}
|
||||
|
||||
/** Rewrite a resolved display title for a CANCELLED tool call ("Stopped <gerund>…"). */
|
||||
export function getToolStoppedTitle(title: string): string {
|
||||
const spaceIndex = title.indexOf(' ')
|
||||
const firstWord = spaceIndex === -1 ? title : title.slice(0, spaceIndex)
|
||||
if (COMPLETED_VERB_REWRITES[firstWord]) {
|
||||
return `Stopped ${firstWord.charAt(0).toLowerCase()}${firstWord.slice(1)}${title.slice(firstWord.length)}`
|
||||
}
|
||||
return `Stopped: ${title}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the final title for a tool status at a rendering boundary. Persisted
|
||||
* and live snapshots intentionally keep the present-tense activity title so a
|
||||
* running/error row remains truthful; every successful renderer calls this to
|
||||
* project the corresponding completed title from the canonical verb map.
|
||||
* RUNNING row remains truthful; terminal states project a tense that says the
|
||||
* work is over — completed (past tense), failed, or stopped.
|
||||
*/
|
||||
export function getToolStatusDisplayTitle(
|
||||
title: string,
|
||||
@@ -1150,5 +1175,8 @@ export function getToolStatusDisplayTitle(
|
||||
if (status === 'success' && toolName === 'browser_request_takeover') {
|
||||
return 'Resumed browser control'
|
||||
}
|
||||
return status === 'success' ? (getToolCompletedTitle(title) ?? title) : title
|
||||
if (status === 'success') return getToolCompletedTitle(title) ?? title
|
||||
if (status === 'error' || status === 'rejected') return getToolFailedTitle(title)
|
||||
if (status === 'cancelled' || status === 'aborted') return getToolStoppedTitle(title)
|
||||
return title
|
||||
}
|
||||
|
||||
@@ -224,6 +224,8 @@ export function serializeRecentExecutions(
|
||||
* but never filters anything.
|
||||
*/
|
||||
export interface KbTagDefinitionSummary {
|
||||
/** The tagDefinitionId that update_tag / delete_tag / update_document.tagValues require. */
|
||||
id: string
|
||||
tagName: string
|
||||
tagSlot: string
|
||||
fieldType: string
|
||||
@@ -872,12 +874,17 @@ export interface DeploymentData {
|
||||
authType: string
|
||||
customizations: unknown
|
||||
isActive: boolean
|
||||
allowedEmails?: unknown
|
||||
outputConfigs?: unknown
|
||||
includeThinking?: boolean | null
|
||||
includeToolCalls?: boolean | null
|
||||
} | null
|
||||
mcp: Array<{
|
||||
serverId: string
|
||||
serverName: string
|
||||
toolId: string
|
||||
toolName: string
|
||||
parameterDescriptionOverrides?: unknown
|
||||
toolDescription?: string | null
|
||||
}>
|
||||
versions?: Array<{
|
||||
@@ -911,6 +918,9 @@ export function serializeDeployments(data: DeploymentData): string {
|
||||
: { isDeployed: false }
|
||||
|
||||
if (data.chat) {
|
||||
// allowedEmails/outputConfigs/includeThinking/includeToolCalls are the
|
||||
// fields deploy_as_chat accepts on redeploy; exposing the current values is
|
||||
// what lets a caller change one setting without blanking the others.
|
||||
result.chat = {
|
||||
id: data.chat.id,
|
||||
identifier: data.chat.identifier,
|
||||
@@ -920,6 +930,10 @@ export function serializeDeployments(data: DeploymentData): string {
|
||||
authType: data.chat.authType,
|
||||
customizations: data.chat.customizations,
|
||||
isActive: data.chat.isActive,
|
||||
allowedEmails: data.chat.allowedEmails ?? undefined,
|
||||
outputConfigs: data.chat.outputConfigs ?? undefined,
|
||||
includeThinking: data.chat.includeThinking ?? undefined,
|
||||
includeToolCalls: data.chat.includeToolCalls ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -930,6 +944,9 @@ export function serializeDeployments(data: DeploymentData): string {
|
||||
toolId: m.toolId,
|
||||
toolName: m.toolName,
|
||||
toolDescription: m.toolDescription || undefined,
|
||||
// What deploy_as_mcp accepts as `parameters` on redeploy; omitting it
|
||||
// there resets the overrides, so expose the current value.
|
||||
parameterDescriptionOverrides: m.parameterDescriptionOverrides ?? undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -1925,6 +1925,7 @@ export class WorkspaceVFS {
|
||||
documentCount: kb.docCount,
|
||||
connectorTypes: kb.connectorTypes,
|
||||
tagDefinitions: tagDefinitions.map((definition) => ({
|
||||
id: definition.id,
|
||||
tagName: definition.displayName,
|
||||
tagSlot: definition.tagSlot,
|
||||
fieldType: definition.fieldType,
|
||||
@@ -2152,6 +2153,10 @@ export class WorkspaceVFS {
|
||||
authType: chatTable.authType,
|
||||
customizations: chatTable.customizations,
|
||||
isActive: chatTable.isActive,
|
||||
allowedEmails: chatTable.allowedEmails,
|
||||
outputConfigs: chatTable.outputConfigs,
|
||||
includeThinking: chatTable.includeThinking,
|
||||
includeToolCalls: chatTable.includeToolCalls,
|
||||
})
|
||||
.from(chatTable)
|
||||
.where(and(eq(chatTable.workflowId, workflowId), isNull(chatTable.archivedAt))),
|
||||
@@ -2162,6 +2167,7 @@ export class WorkspaceVFS {
|
||||
toolId: workflowMcpTool.id,
|
||||
toolName: workflowMcpTool.toolName,
|
||||
toolDescription: workflowMcpTool.toolDescription,
|
||||
parameterDescriptionOverrides: workflowMcpTool.parameterDescriptionOverrides,
|
||||
})
|
||||
.from(workflowMcpTool)
|
||||
.innerJoin(workflowMcpServer, eq(workflowMcpTool.serverId, workflowMcpServer.id))
|
||||
|
||||
@@ -95,6 +95,7 @@ export interface ListArchivedKnowledgeBasesResult {
|
||||
}
|
||||
|
||||
export interface KnowledgeBaseCatalogTagDefinition {
|
||||
id: string
|
||||
knowledgeBaseId: string
|
||||
tagSlot: string
|
||||
displayName: string
|
||||
@@ -386,6 +387,7 @@ export const listKnowledgeBaseCatalog = defineAuthorizedKnowledgeUseCase({
|
||||
? []
|
||||
: await db
|
||||
.select({
|
||||
id: knowledgeBaseTagDefinitions.id,
|
||||
knowledgeBaseId: knowledgeBaseTagDefinitions.knowledgeBaseId,
|
||||
tagSlot: knowledgeBaseTagDefinitions.tagSlot,
|
||||
displayName: knowledgeBaseTagDefinitions.displayName,
|
||||
|
||||
@@ -178,7 +178,11 @@ export async function performCreateKnowledgeConnector(
|
||||
|
||||
const configValidation = await connectorConfig.validateConfig(accessToken, sourceConfig)
|
||||
if (!configValidation.valid) {
|
||||
return fail(configValidation.error || 'Invalid source configuration', 'validation')
|
||||
return fail(
|
||||
configValidation.error ||
|
||||
`The ${connectorType} connector rejected sourceConfig without a reason — re-check its required fields in knowledgebases/connectors/${connectorType}.json before retrying; the same config will fail again.`,
|
||||
'validation'
|
||||
)
|
||||
}
|
||||
|
||||
if (connectorConfig.auth.mode === 'apiKey' && apiKey) {
|
||||
|
||||
@@ -47,7 +47,10 @@ describe('table application context', () => {
|
||||
it('conceals an asserted cross-workspace table before workspace resolution', async () => {
|
||||
await expect(
|
||||
resolveActiveTableContext({ tableId: 'table-1', assertedWorkspaceId: 'workspace-2' })
|
||||
).rejects.toMatchObject({ code: 'not_found', message: 'Table not found' })
|
||||
).rejects.toMatchObject({
|
||||
code: 'not_found',
|
||||
message: expect.stringContaining('not found in this workspace'),
|
||||
})
|
||||
expect(loadWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -27,7 +27,12 @@ export async function resolveActiveTableContext(input: {
|
||||
!table ||
|
||||
(input.assertedWorkspaceId !== undefined && table.workspaceId !== input.assertedWorkspaceId)
|
||||
) {
|
||||
throw new OrchestrationError('not_found', 'Table not found')
|
||||
// One message for "no such table" and "table in another workspace" so
|
||||
// existence never leaks across workspaces — but actionable either way.
|
||||
throw new OrchestrationError(
|
||||
'not_found',
|
||||
`Table "${input.tableId}" not found in this workspace — it may not exist or may belong to a different workspace. Run glob("tables/*") to list the tables you can use here.`
|
||||
)
|
||||
}
|
||||
const workspaceContext = await resolveTableWorkspaceContext(table.workspaceId)
|
||||
return { ...workspaceContext, tableId: table.id, table }
|
||||
|
||||
@@ -224,7 +224,10 @@ export const updateTableUseCase = defineAuthorizedTableUseCase({
|
||||
|
||||
const table = await getTableById(current.id)
|
||||
if (!table || table.workspaceId !== context.workspaceId) {
|
||||
throw new OrchestrationError('not_found', 'Table not found')
|
||||
throw new OrchestrationError(
|
||||
'not_found',
|
||||
'Table not found in this workspace — run glob("tables/*") to list valid tables'
|
||||
)
|
||||
}
|
||||
const index =
|
||||
resolution?.index ??
|
||||
@@ -295,7 +298,11 @@ export const deleteTableUseCase = defineAuthorizedTableUseCase({
|
||||
const { archived } = await deleteTable(context.table.id, generateRequestId(), {
|
||||
expectedWorkspaceId: context.workspaceId,
|
||||
})
|
||||
if (!archived) throw new OrchestrationError('not_found', 'Table not found')
|
||||
if (!archived)
|
||||
throw new OrchestrationError(
|
||||
'not_found',
|
||||
'Table not found in this workspace — run glob("tables/*") to list valid tables'
|
||||
)
|
||||
return {
|
||||
id: context.table.id,
|
||||
deleted: true as const,
|
||||
|
||||
@@ -61,7 +61,11 @@ export const readTableViewUseCase = defineAuthorizedTableUseCase({
|
||||
(context.table.schema as TableSchema).columns,
|
||||
context.workspaceId
|
||||
)
|
||||
if (!view) throw new OrchestrationError('not_found', 'View not found')
|
||||
if (!view)
|
||||
throw new OrchestrationError(
|
||||
'not_found',
|
||||
'View not found on this table — call table_views with operation "list_views" for valid view ids'
|
||||
)
|
||||
return { view, table: context.table }
|
||||
},
|
||||
})
|
||||
@@ -130,7 +134,11 @@ export const updateTableViewUseCase = defineAuthorizedTableUseCase({
|
||||
(context.table.schema as TableSchema).columns,
|
||||
context.workspaceId
|
||||
)
|
||||
if (!existing) throw new OrchestrationError('not_found', 'View not found')
|
||||
if (!existing)
|
||||
throw new OrchestrationError(
|
||||
'not_found',
|
||||
'View not found on this table — call table_views with operation "list_views" for valid view ids'
|
||||
)
|
||||
const view = await updateTableView({
|
||||
viewId: input.viewId,
|
||||
tableId: context.table.id,
|
||||
@@ -141,7 +149,11 @@ export const updateTableViewUseCase = defineAuthorizedTableUseCase({
|
||||
isDefault: input.isDefault,
|
||||
columns: (context.table.schema as TableSchema).columns,
|
||||
})
|
||||
if (!view) throw new OrchestrationError('not_found', 'View not found')
|
||||
if (!view)
|
||||
throw new OrchestrationError(
|
||||
'not_found',
|
||||
'View not found on this table — call table_views with operation "list_views" for valid view ids'
|
||||
)
|
||||
return {
|
||||
view,
|
||||
table: context.table,
|
||||
@@ -181,9 +193,17 @@ export const deleteTableViewUseCase = defineAuthorizedTableUseCase({
|
||||
(context.table.schema as TableSchema).columns,
|
||||
context.workspaceId
|
||||
)
|
||||
if (!existing) throw new OrchestrationError('not_found', 'View not found')
|
||||
if (!existing)
|
||||
throw new OrchestrationError(
|
||||
'not_found',
|
||||
'View not found on this table — call table_views with operation "list_views" for valid view ids'
|
||||
)
|
||||
const deleted = await deleteTableView(input.viewId, context.table.id, context.workspaceId)
|
||||
if (!deleted) throw new OrchestrationError('not_found', 'View not found')
|
||||
if (!deleted)
|
||||
throw new OrchestrationError(
|
||||
'not_found',
|
||||
'View not found on this table — call table_views with operation "list_views" for valid view ids'
|
||||
)
|
||||
return { viewId: input.viewId, viewName: existing.name, table: context.table }
|
||||
},
|
||||
projectAudit({ result }) {
|
||||
|
||||
@@ -65,19 +65,7 @@ export interface PerformChatDeployResult {
|
||||
export async function performChatDeploy(
|
||||
params: ChatDeployPayload
|
||||
): Promise<PerformChatDeployResult> {
|
||||
const {
|
||||
workflowId,
|
||||
userId,
|
||||
identifier,
|
||||
title,
|
||||
description = '',
|
||||
authType = 'public',
|
||||
password,
|
||||
allowedEmails = [],
|
||||
outputConfigs = [],
|
||||
includeThinking = false,
|
||||
includeToolCalls = false,
|
||||
} = params
|
||||
const { workflowId, userId, identifier, title, password } = params
|
||||
|
||||
/**
|
||||
* Validate the password here rather than only at the HTTP boundary. The
|
||||
@@ -93,10 +81,60 @@ export async function performChatDeploy(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeploys merge: any field the caller omitted keeps the existing chat's
|
||||
* value instead of being reset to a default. Before this, a copilot
|
||||
* `deploy_as_chat` call that changed only the title silently flipped an
|
||||
* email/sso-protected chat back to public, wiped its allowlist and output
|
||||
* configuration, and reset the welcome customizations — the caller had no
|
||||
* way to know, because none of those fields were readable back. Defaults
|
||||
* apply only when there is no existing deployment to preserve.
|
||||
*/
|
||||
const [existingDeployment] = await db
|
||||
.select()
|
||||
.from(chat)
|
||||
.where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
const authType =
|
||||
params.authType ??
|
||||
(existingDeployment?.authType as ChatDeployPayload['authType'] | undefined) ??
|
||||
'public'
|
||||
const description =
|
||||
params.description !== undefined ? params.description : (existingDeployment?.description ?? '')
|
||||
const allowedEmails =
|
||||
params.allowedEmails ?? (existingDeployment?.allowedEmails as string[] | null) ?? []
|
||||
const outputConfigs =
|
||||
params.outputConfigs ??
|
||||
(existingDeployment?.outputConfigs as Array<{ blockId: string; path: string }> | null) ??
|
||||
[]
|
||||
const includeThinking = params.includeThinking ?? existingDeployment?.includeThinking ?? false
|
||||
const includeToolCalls = params.includeToolCalls ?? existingDeployment?.includeToolCalls ?? false
|
||||
|
||||
// Per-field merge (params over existing over defaults): callers routinely
|
||||
// send a customizations object with only some fields set, and a hard default
|
||||
// for the rest silently reset the chat's colors and welcome message.
|
||||
const existingCustomizations =
|
||||
existingDeployment?.customizations &&
|
||||
typeof existingDeployment.customizations === 'object' &&
|
||||
!Array.isArray(existingDeployment.customizations)
|
||||
? (existingDeployment.customizations as {
|
||||
primaryColor?: string
|
||||
welcomeMessage?: string
|
||||
imageUrl?: string
|
||||
})
|
||||
: undefined
|
||||
const mergedImageUrl = params.customizations?.imageUrl || existingCustomizations?.imageUrl
|
||||
const customizations = {
|
||||
primaryColor: params.customizations?.primaryColor || 'var(--brand-hover)',
|
||||
welcomeMessage: params.customizations?.welcomeMessage || 'Hi there! How can I help you today?',
|
||||
...(params.customizations?.imageUrl ? { imageUrl: params.customizations.imageUrl } : {}),
|
||||
primaryColor:
|
||||
params.customizations?.primaryColor ||
|
||||
existingCustomizations?.primaryColor ||
|
||||
'var(--brand-hover)',
|
||||
welcomeMessage:
|
||||
params.customizations?.welcomeMessage ||
|
||||
existingCustomizations?.welcomeMessage ||
|
||||
'Hi there! How can I help you today?',
|
||||
...(mergedImageUrl ? { imageUrl: mergedImageUrl } : {}),
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,12 +200,6 @@ export async function performChatDeploy(
|
||||
encryptedPassword = encrypted
|
||||
}
|
||||
|
||||
const [existingDeployment] = await db
|
||||
.select()
|
||||
.from(chat)
|
||||
.where(and(eq(chat.workflowId, workflowId), isNull(chat.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
/**
|
||||
* A password-protected chat must end up with a stored password. Both HTTP
|
||||
* routes already reject this; without the same guard here a copilot
|
||||
|
||||
+23
-3
@@ -350,12 +350,29 @@ async function resolveCopilotEnvReferences(
|
||||
return
|
||||
}
|
||||
|
||||
const pending: Array<{ paramId: string; value: string }> = []
|
||||
// Models improvise reference syntax: after `{{NAME}}`, `$NAME` and the bare
|
||||
// variable name are the common fallbacks — both previously went upstream as
|
||||
// the literal credential and failed with an undiagnosable 401. `{{NAME}}`
|
||||
// and `$NAME` are unambiguous references (a real key never starts with `$`),
|
||||
// so a missing variable is a hard error. A bare name is a reference only
|
||||
// when a variable by that exact name exists (`soft`): plenty of real API
|
||||
// keys match the identifier pattern, and those must pass through verbatim.
|
||||
const pending: Array<{ paramId: string; value: string; soft?: boolean }> = []
|
||||
for (const [paramId, paramDef] of Object.entries(tool.params || {})) {
|
||||
if (paramDef?.visibility !== 'user-only') continue
|
||||
const value = params[paramId]
|
||||
if (typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}')) {
|
||||
if (typeof value !== 'string') continue
|
||||
if (value.startsWith('{{') && value.endsWith('}}')) {
|
||||
pending.push({ paramId, value })
|
||||
continue
|
||||
}
|
||||
const dollar = value.match(/^\$([A-Za-z_][A-Za-z0-9_]*)$/)
|
||||
if (dollar) {
|
||||
pending.push({ paramId, value: `{{${dollar[1]}}}` })
|
||||
continue
|
||||
}
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
|
||||
pending.push({ paramId, value: `{{${value}}}`, soft: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,7 +391,7 @@ async function resolveCopilotEnvReferences(
|
||||
const { getEffectiveDecryptedEnv } = await import('@/lib/environment/utils')
|
||||
const envVars = await getEffectiveDecryptedEnv(scope.userId, scope.workspaceId)
|
||||
|
||||
for (const { paramId, value } of pending) {
|
||||
for (const { paramId, value, soft } of pending) {
|
||||
const missingKeys: string[] = []
|
||||
const resolved = resolveEnvVarReferences(value, envVars, {
|
||||
allowEmbedded: false,
|
||||
@@ -386,6 +403,9 @@ async function resolveCopilotEnvReferences(
|
||||
},
|
||||
})
|
||||
if (missingKeys.length > 0) {
|
||||
// A bare name that matches no variable is treated as the literal
|
||||
// credential it probably is; only explicit reference forms error.
|
||||
if (soft) continue
|
||||
const scopeHint = scope.workspaceId
|
||||
? ''
|
||||
: ' (no workspace context — only personal variables are available here)'
|
||||
|
||||
@@ -617,6 +617,16 @@ export function createUserToolSchema(
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
// Copilot agents never see secret values, only names — so tell them the
|
||||
// reference form works here, or they paste placeholders that fail upstream.
|
||||
if (visibility === 'user-only' && surface === 'copilot') {
|
||||
propertySchema.description = [
|
||||
propertySchema.description,
|
||||
'Accepts an environment-variable reference like {{VAR_NAME}} (see environment/variables.json), resolved server-side.',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
schema.properties[paramId] = propertySchema
|
||||
|
||||
if (param.required && paramId !== hostedApiKeyParam) {
|
||||
|
||||
Reference in New Issue
Block a user