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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -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,
}
},
}