chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*
|
||||
* Regression test: the credentials response must expose only display metadata,
|
||||
* never the connected account's OAuth access/refresh token.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const SECRET_ACCESS_TOKEN = 'ya29.a0SECRET_GOOGLE_BEARER_TOKEN_DO_NOT_LEAK'
|
||||
|
||||
const { selectMock, getAllOAuthServicesMock, getPersonalAndWorkspaceEnvMock, decodeJwtMock } =
|
||||
vi.hoisted(() => ({
|
||||
selectMock: vi.fn(),
|
||||
getAllOAuthServicesMock: vi.fn(),
|
||||
getPersonalAndWorkspaceEnvMock: vi.fn(),
|
||||
decodeJwtMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: { select: selectMock },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/oauth', () => ({
|
||||
getAllOAuthServices: getAllOAuthServicesMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/environment/utils', () => ({
|
||||
getPersonalAndWorkspaceEnv: getPersonalAndWorkspaceEnvMock,
|
||||
}))
|
||||
|
||||
vi.mock('jose', () => ({
|
||||
decodeJwt: decodeJwtMock,
|
||||
}))
|
||||
|
||||
import { getCredentialsServerTool } from './get-credentials'
|
||||
|
||||
/**
|
||||
* Wires the two sequential `db.select()` reads the tool performs:
|
||||
* 1. `select().from(account).where()` → account rows (awaited directly)
|
||||
* 2. `select({...}).from(user).where().limit(1)` → user row
|
||||
*/
|
||||
function wireDb(accountRows: unknown[], userRows: Array<{ email: string }>) {
|
||||
const whereThenable = {
|
||||
then: (resolve: (rows: unknown[]) => unknown) => resolve(accountRows),
|
||||
limit: () => Promise.resolve(userRows),
|
||||
}
|
||||
const builder = { from: () => builder, where: () => whereThenable }
|
||||
selectMock.mockReturnValue(builder)
|
||||
}
|
||||
|
||||
describe('getCredentialsServerTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
wireDb(
|
||||
[
|
||||
{
|
||||
id: 'acct-google-1',
|
||||
providerId: 'google-default',
|
||||
accountId: '1234567890',
|
||||
idToken: 'jwt-token',
|
||||
accessToken: SECRET_ACCESS_TOKEN,
|
||||
refreshToken: 'refresh-secret',
|
||||
updatedAt: new Date('2026-04-17T02:26:05.546Z'),
|
||||
},
|
||||
],
|
||||
[{ email: 'brent@cellular.so' }]
|
||||
)
|
||||
|
||||
getAllOAuthServicesMock.mockReturnValue([
|
||||
{
|
||||
providerId: 'google-default',
|
||||
name: 'Google',
|
||||
description: 'Google account',
|
||||
baseProvider: 'google',
|
||||
},
|
||||
{
|
||||
providerId: 'slack',
|
||||
name: 'Slack',
|
||||
description: 'Slack workspace',
|
||||
baseProvider: 'slack',
|
||||
},
|
||||
])
|
||||
|
||||
getPersonalAndWorkspaceEnvMock.mockResolvedValue({
|
||||
personalEncrypted: {},
|
||||
workspaceEncrypted: {},
|
||||
conflicts: [],
|
||||
})
|
||||
|
||||
decodeJwtMock.mockReturnValue({ email: 'brent@cellular.so' })
|
||||
})
|
||||
|
||||
it('never returns access tokens for connected OAuth credentials', async () => {
|
||||
const result = await getCredentialsServerTool.execute({}, { userId: 'user-1' })
|
||||
|
||||
const credentials = result.oauth.connected.credentials
|
||||
expect(credentials).toHaveLength(1)
|
||||
|
||||
for (const credential of credentials) {
|
||||
expect(credential).not.toHaveProperty('accessToken')
|
||||
expect(credential).not.toHaveProperty('refreshToken')
|
||||
expect(credential).not.toHaveProperty('idToken')
|
||||
}
|
||||
})
|
||||
|
||||
it('returns only masked display metadata for each credential', async () => {
|
||||
const result = await getCredentialsServerTool.execute({}, { userId: 'user-1' })
|
||||
|
||||
expect(result.oauth.connected.credentials[0]).toEqual({
|
||||
id: 'acct-google-1',
|
||||
name: 'brent@cellular.so',
|
||||
provider: 'google-default',
|
||||
serviceName: 'Google',
|
||||
lastUsed: '2026-04-17T02:26:05.546Z',
|
||||
isDefault: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not leak the token value anywhere in the serialized response', async () => {
|
||||
const result = await getCredentialsServerTool.execute({}, { userId: 'user-1' })
|
||||
|
||||
expect(JSON.stringify(result)).not.toContain(SECRET_ACCESS_TOKEN)
|
||||
expect(JSON.stringify(result)).not.toContain('refresh-secret')
|
||||
})
|
||||
|
||||
it('rejects unauthenticated callers without touching the database', async () => {
|
||||
await expect(getCredentialsServerTool.execute({}, undefined)).rejects.toThrow(
|
||||
'Authentication required'
|
||||
)
|
||||
expect(selectMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
import { db } from '@sim/db'
|
||||
import { account, user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { decodeJwt } from 'jose'
|
||||
import { createPermissionError, verifyWorkflowAccess } from '@/lib/copilot/auth/permissions'
|
||||
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
|
||||
import { getAccessibleOAuthCredentials } from '@/lib/credentials/environment'
|
||||
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
|
||||
import { getAllOAuthServices } from '@/lib/oauth'
|
||||
import { checkWorkspaceAccess, type WorkspaceAccess } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
interface GetCredentialsParams {
|
||||
workflowId?: string
|
||||
}
|
||||
|
||||
export const getCredentialsServerTool: BaseServerTool<GetCredentialsParams, any> = {
|
||||
name: 'get_credentials',
|
||||
async execute(params: GetCredentialsParams, context?: { userId: string }): Promise<any> {
|
||||
const logger = createLogger('GetCredentialsServerTool')
|
||||
|
||||
if (!context?.userId) {
|
||||
logger.error('Unauthorized attempt to access credentials - no authenticated user context')
|
||||
throw new Error('Authentication required')
|
||||
}
|
||||
|
||||
const authenticatedUserId = context.userId
|
||||
|
||||
let workspaceId: string | undefined
|
||||
|
||||
if (params?.workflowId) {
|
||||
const { hasAccess, workspaceId: wId } = await verifyWorkflowAccess(
|
||||
authenticatedUserId,
|
||||
params.workflowId
|
||||
)
|
||||
|
||||
if (!hasAccess) {
|
||||
const errorMessage = createPermissionError('access credentials in')
|
||||
logger.error('Unauthorized attempt to access credentials', {
|
||||
workflowId: params.workflowId,
|
||||
authenticatedUserId,
|
||||
})
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
workspaceId = wId
|
||||
}
|
||||
|
||||
const userId = authenticatedUserId
|
||||
|
||||
// Resolve workspace access once and thread it into both credential lookups
|
||||
// below; each would otherwise re-resolve the same workspace-admin status.
|
||||
const workspaceAccess: WorkspaceAccess | undefined = workspaceId
|
||||
? await checkWorkspaceAccess(workspaceId, userId)
|
||||
: undefined
|
||||
|
||||
logger.info('Fetching credentials for authenticated user', {
|
||||
userId,
|
||||
hasWorkflowId: !!params?.workflowId,
|
||||
})
|
||||
|
||||
// Fetch OAuth credentials
|
||||
const accounts = await db.select().from(account).where(eq(account.userId, userId))
|
||||
const userRecord = await db
|
||||
.select({ email: user.email })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1)
|
||||
const userEmail = userRecord.length > 0 ? userRecord[0]?.email : null
|
||||
|
||||
// Get all available OAuth services
|
||||
const allOAuthServices = getAllOAuthServices()
|
||||
|
||||
// Track connected provider IDs
|
||||
const connectedProviderIds = new Set<string>()
|
||||
|
||||
const connectedCredentials: Array<{
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
serviceName: string
|
||||
lastUsed: string
|
||||
isDefault: boolean
|
||||
}> = []
|
||||
|
||||
for (const acc of accounts) {
|
||||
const providerId = acc.providerId
|
||||
connectedProviderIds.add(providerId)
|
||||
|
||||
const [baseProvider, featureType = 'default'] = providerId.split('-')
|
||||
let displayName = ''
|
||||
if (acc.idToken) {
|
||||
try {
|
||||
const decoded = decodeJwt<{ email?: string; name?: string }>(acc.idToken)
|
||||
displayName = decoded.email || decoded.name || ''
|
||||
} catch (error) {
|
||||
logger.warn('Failed to decode JWT id token', {
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!displayName && baseProvider === 'github') displayName = `${acc.accountId} (GitHub)`
|
||||
if (!displayName && userEmail) displayName = userEmail
|
||||
if (!displayName) displayName = `${acc.accountId} (${baseProvider})`
|
||||
|
||||
// Find the service name for this provider ID
|
||||
const service = allOAuthServices.find((s) => s.providerId === providerId)
|
||||
const serviceName = service?.name ?? providerId
|
||||
|
||||
connectedCredentials.push({
|
||||
id: acc.id,
|
||||
name: displayName,
|
||||
provider: providerId,
|
||||
serviceName,
|
||||
lastUsed: acc.updatedAt.toISOString(),
|
||||
isDefault: featureType === 'default',
|
||||
})
|
||||
}
|
||||
|
||||
// Surface workspace-shared OAuth/service-account credentials the user can use,
|
||||
// including those they reach as a derived workspace admin (not just their own
|
||||
// personal account connections). Keyed by credential id so the agent references
|
||||
// the workspace credential, not a legacy account id.
|
||||
if (workspaceId) {
|
||||
const sharedCredentials = await getAccessibleOAuthCredentials(workspaceId, userId, {
|
||||
isWorkspaceAdmin: workspaceAccess?.canAdmin ?? false,
|
||||
})
|
||||
const seenCredentialIds = new Set(connectedCredentials.map((c) => c.id))
|
||||
for (const cred of sharedCredentials) {
|
||||
if (seenCredentialIds.has(cred.id)) continue
|
||||
connectedProviderIds.add(cred.providerId)
|
||||
const [, featureType = 'default'] = cred.providerId.split('-')
|
||||
connectedCredentials.push({
|
||||
id: cred.id,
|
||||
name: cred.displayName,
|
||||
provider: cred.providerId,
|
||||
serviceName:
|
||||
allOAuthServices.find((s) => s.providerId === cred.providerId)?.name ?? cred.providerId,
|
||||
lastUsed: cred.updatedAt.toISOString(),
|
||||
isDefault: featureType === 'default',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Build list of not connected services
|
||||
const notConnectedServices = allOAuthServices
|
||||
.filter((service) => !connectedProviderIds.has(service.providerId))
|
||||
.map((service) => ({
|
||||
providerId: service.providerId,
|
||||
name: service.name,
|
||||
description: service.description,
|
||||
baseProvider: service.baseProvider,
|
||||
}))
|
||||
|
||||
// Fetch environment variables from both personal and workspace
|
||||
const envResult = await getPersonalAndWorkspaceEnv(
|
||||
userId,
|
||||
workspaceId,
|
||||
workspaceAccess ? { workspaceAccess } : undefined
|
||||
)
|
||||
|
||||
// Get all unique variable names from both personal and workspace
|
||||
const personalVarNames = Object.keys(envResult.personalEncrypted)
|
||||
const workspaceVarNames = Object.keys(envResult.workspaceEncrypted)
|
||||
const allVarNames = [...new Set([...personalVarNames, ...workspaceVarNames])]
|
||||
|
||||
logger.info('Fetched credentials', {
|
||||
userId,
|
||||
workspaceId,
|
||||
connectedCount: connectedCredentials.length,
|
||||
notConnectedCount: notConnectedServices.length,
|
||||
personalEnvVarCount: personalVarNames.length,
|
||||
workspaceEnvVarCount: workspaceVarNames.length,
|
||||
totalEnvVarCount: allVarNames.length,
|
||||
conflicts: envResult.conflicts,
|
||||
})
|
||||
|
||||
return {
|
||||
oauth: {
|
||||
connected: {
|
||||
credentials: connectedCredentials,
|
||||
total: connectedCredentials.length,
|
||||
},
|
||||
notConnected: {
|
||||
services: notConnectedServices,
|
||||
total: notConnectedServices.length,
|
||||
},
|
||||
},
|
||||
environment: {
|
||||
variableNames: allVarNames,
|
||||
count: allVarNames.length,
|
||||
personalVariables: personalVarNames,
|
||||
workspaceVariables: workspaceVarNames,
|
||||
conflicts: envResult.conflicts,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
ensureWorkflowAccessMock,
|
||||
ensureWorkspaceAccessMock,
|
||||
getDefaultWorkspaceIdMock,
|
||||
upsertPersonalEnvVarsMock,
|
||||
upsertWorkspaceEnvVarsMock,
|
||||
} = vi.hoisted(() => ({
|
||||
ensureWorkflowAccessMock: vi.fn(),
|
||||
ensureWorkspaceAccessMock: vi.fn(),
|
||||
getDefaultWorkspaceIdMock: vi.fn(),
|
||||
upsertPersonalEnvVarsMock: vi.fn(),
|
||||
upsertWorkspaceEnvVarsMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/tools/handlers/access', () => ({
|
||||
ensureWorkflowAccess: ensureWorkflowAccessMock,
|
||||
ensureWorkspaceAccess: ensureWorkspaceAccessMock,
|
||||
getDefaultWorkspaceId: getDefaultWorkspaceIdMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/environment/utils', () => ({
|
||||
upsertPersonalEnvVars: upsertPersonalEnvVarsMock,
|
||||
upsertWorkspaceEnvVars: upsertWorkspaceEnvVarsMock,
|
||||
}))
|
||||
|
||||
import { setEnvironmentVariablesServerTool } from './set-environment-variables'
|
||||
|
||||
describe('setEnvironmentVariablesServerTool', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
ensureWorkflowAccessMock.mockResolvedValue({
|
||||
workflow: { id: 'wf-1', workspaceId: 'ws-from-workflow' },
|
||||
})
|
||||
ensureWorkspaceAccessMock.mockResolvedValue(undefined)
|
||||
getDefaultWorkspaceIdMock.mockResolvedValue('ws-default')
|
||||
upsertPersonalEnvVarsMock.mockResolvedValue({ added: ['API_KEY'], updated: [] })
|
||||
upsertWorkspaceEnvVarsMock.mockResolvedValue(['API_KEY'])
|
||||
})
|
||||
|
||||
it('defaults to workspace scope and uses the current workspace context', async () => {
|
||||
const result = await setEnvironmentVariablesServerTool.execute(
|
||||
{
|
||||
variables: [{ name: 'API_KEY', value: 'secret' }],
|
||||
},
|
||||
{
|
||||
userId: 'user-1',
|
||||
workspaceId: 'ws-1',
|
||||
}
|
||||
)
|
||||
|
||||
expect(ensureWorkspaceAccessMock).toHaveBeenCalledWith('ws-1', 'user-1', 'write')
|
||||
expect(upsertWorkspaceEnvVarsMock).toHaveBeenCalledWith('ws-1', { API_KEY: 'secret' }, 'user-1')
|
||||
expect(upsertPersonalEnvVarsMock).not.toHaveBeenCalled()
|
||||
expect(result.scope).toBe('workspace')
|
||||
expect(result.workspaceId).toBe('ws-1')
|
||||
})
|
||||
|
||||
it('supports explicit personal scope', async () => {
|
||||
const result = await setEnvironmentVariablesServerTool.execute(
|
||||
{
|
||||
scope: 'personal',
|
||||
variables: [{ name: 'API_KEY', value: 'secret' }],
|
||||
},
|
||||
{
|
||||
userId: 'user-1',
|
||||
workspaceId: 'ws-1',
|
||||
}
|
||||
)
|
||||
|
||||
expect(upsertPersonalEnvVarsMock).toHaveBeenCalledWith('user-1', { API_KEY: 'secret' })
|
||||
expect(upsertWorkspaceEnvVarsMock).not.toHaveBeenCalled()
|
||||
expect(ensureWorkspaceAccessMock).not.toHaveBeenCalled()
|
||||
expect(result.scope).toBe('personal')
|
||||
})
|
||||
|
||||
it('falls back to the default workspace when none is in context', async () => {
|
||||
await setEnvironmentVariablesServerTool.execute(
|
||||
{
|
||||
variables: [{ name: 'API_KEY', value: 'secret' }],
|
||||
},
|
||||
{
|
||||
userId: 'user-1',
|
||||
}
|
||||
)
|
||||
|
||||
expect(getDefaultWorkspaceIdMock).toHaveBeenCalledWith('user-1')
|
||||
expect(upsertWorkspaceEnvVarsMock).toHaveBeenCalledWith(
|
||||
'ws-default',
|
||||
{ API_KEY: 'secret' },
|
||||
'user-1'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { z } from 'zod'
|
||||
import { SetEnvironmentVariables } from '@/lib/copilot/generated/tool-catalog-v1'
|
||||
import {
|
||||
ensureWorkflowAccess,
|
||||
ensureWorkspaceAccess,
|
||||
getDefaultWorkspaceId,
|
||||
} from '@/lib/copilot/tools/handlers/access'
|
||||
import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool'
|
||||
import { upsertPersonalEnvVars, upsertWorkspaceEnvVars } from '@/lib/environment/utils'
|
||||
|
||||
type EnvironmentVariableInputValue = string | number | boolean | null | undefined
|
||||
|
||||
interface EnvironmentVariableInput {
|
||||
name: string
|
||||
value: EnvironmentVariableInputValue
|
||||
}
|
||||
|
||||
interface SetEnvironmentVariablesParams {
|
||||
variables: Record<string, EnvironmentVariableInputValue> | EnvironmentVariableInput[]
|
||||
scope?: 'personal' | 'workspace'
|
||||
workflowId?: string
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
interface SetEnvironmentVariablesResult {
|
||||
message: string
|
||||
scope: 'personal' | 'workspace'
|
||||
workspaceId?: string
|
||||
variableCount: number
|
||||
variableNames: string[]
|
||||
addedVariables: string[]
|
||||
updatedVariables: string[]
|
||||
workspaceUpdatedVariables: string[]
|
||||
}
|
||||
|
||||
const EnvVarSchema = z.object({ variables: z.record(z.string(), z.string()) })
|
||||
|
||||
function normalizeVariables(
|
||||
input: Record<string, EnvironmentVariableInputValue> | EnvironmentVariableInput[]
|
||||
): Record<string, string> {
|
||||
if (Array.isArray(input)) {
|
||||
return input.reduce(
|
||||
(acc, item) => {
|
||||
if (item && typeof item.name === 'string') {
|
||||
acc[item.name] = String(item.value ?? '')
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, string>
|
||||
)
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(input || {}).map(([k, v]) => [k, String(v ?? '')])
|
||||
) as Record<string, string>
|
||||
}
|
||||
|
||||
async function resolveWorkspaceId(
|
||||
params: SetEnvironmentVariablesParams,
|
||||
context: ServerToolContext | undefined,
|
||||
userId: string
|
||||
): Promise<string> {
|
||||
if (params.workflowId) {
|
||||
const { workflow } = await ensureWorkflowAccess(params.workflowId, userId, 'write')
|
||||
if (!workflow.workspaceId) {
|
||||
throw new Error(`Workflow ${params.workflowId} is not associated with a workspace`)
|
||||
}
|
||||
return workflow.workspaceId
|
||||
}
|
||||
|
||||
const workspaceId = params.workspaceId ?? context?.workspaceId
|
||||
if (workspaceId) {
|
||||
await ensureWorkspaceAccess(workspaceId, userId, 'write')
|
||||
return workspaceId
|
||||
}
|
||||
|
||||
return getDefaultWorkspaceId(userId)
|
||||
}
|
||||
|
||||
export const setEnvironmentVariablesServerTool: BaseServerTool<
|
||||
SetEnvironmentVariablesParams,
|
||||
SetEnvironmentVariablesResult
|
||||
> = {
|
||||
name: SetEnvironmentVariables.id,
|
||||
async execute(
|
||||
params: SetEnvironmentVariablesParams,
|
||||
context?: ServerToolContext
|
||||
): Promise<SetEnvironmentVariablesResult> {
|
||||
const logger = createLogger('SetEnvironmentVariablesServerTool')
|
||||
|
||||
if (!context?.userId) {
|
||||
logger.error(
|
||||
'Unauthorized attempt to set environment variables - no authenticated user context'
|
||||
)
|
||||
throw new Error('Authentication required')
|
||||
}
|
||||
|
||||
const authenticatedUserId = context.userId
|
||||
const { variables } = params || ({} as SetEnvironmentVariablesParams)
|
||||
const scope = params.scope === 'personal' ? 'personal' : 'workspace'
|
||||
|
||||
const normalized = normalizeVariables(variables || {})
|
||||
const { variables: validatedVariables } = EnvVarSchema.parse({ variables: normalized })
|
||||
const variableNames = Object.keys(validatedVariables)
|
||||
const added: string[] = []
|
||||
const updated: string[] = []
|
||||
let workspaceUpdated: string[] = []
|
||||
|
||||
let resolvedWorkspaceId: string | undefined
|
||||
if (scope === 'workspace') {
|
||||
resolvedWorkspaceId = await resolveWorkspaceId(params, context, authenticatedUserId)
|
||||
workspaceUpdated = await upsertWorkspaceEnvVars(
|
||||
resolvedWorkspaceId,
|
||||
validatedVariables,
|
||||
authenticatedUserId
|
||||
)
|
||||
} else {
|
||||
const result = await upsertPersonalEnvVars(authenticatedUserId, validatedVariables)
|
||||
added.push(...result.added)
|
||||
updated.push(...result.updated)
|
||||
}
|
||||
|
||||
const totalProcessed = added.length + updated.length + workspaceUpdated.length
|
||||
|
||||
logger.info('Saved environment variables', {
|
||||
userId: authenticatedUserId,
|
||||
scope,
|
||||
addedCount: added.length,
|
||||
updatedCount: updated.length,
|
||||
workspaceUpdatedCount: workspaceUpdated.length,
|
||||
workspaceId: resolvedWorkspaceId,
|
||||
})
|
||||
|
||||
const parts: string[] = []
|
||||
if (added.length > 0) parts.push(`${added.length} personal secret(s) added`)
|
||||
if (updated.length > 0) parts.push(`${updated.length} personal secret(s) updated`)
|
||||
if (workspaceUpdated.length > 0)
|
||||
parts.push(`${workspaceUpdated.length} workspace secret(s) updated`)
|
||||
|
||||
return {
|
||||
message: `Successfully processed ${totalProcessed} secret(s): ${parts.join(', ')}`,
|
||||
scope,
|
||||
workspaceId: resolvedWorkspaceId,
|
||||
variableCount: variableNames.length,
|
||||
variableNames,
|
||||
addedVariables: added,
|
||||
updatedVariables: updated,
|
||||
workspaceUpdatedVariables: workspaceUpdated,
|
||||
}
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user